diff --git a/.zed/settings.json b/.zed/settings.json index c39ed302..ea741556 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -46,7 +46,7 @@ "exclude": [ "target", "vendor", - "src/ghostty/bindings.rs" + "crates/ghostty-vt/src/bindings.rs" ] }, "lru": { diff --git a/AGENTS.md b/AGENTS.md index 26446d4f..8df9f1e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,11 @@ Detection rules are evidence-based. When changing `src/detect/manifests/`, first Inspect matching with `herdr agent explain --json`, test a rule through a local override in `~/.config/herdr-dev/agent-detection/.toml` (debug) or `~/.config/herdr/agent-detection/.toml`, and apply it with `herdr server reload-agent-manifests`. Never overwrite or remove an existing override without the owner's agreement, and remove the temporary override afterwards so the bundled manifest stays the source of truth. -Detection fixes that apply equally to Herdr belong upstream. Keep `distribution/agent-detection/` aligned with the bundled manifests; `scripts/agent_detection_manifest_check.py` enforces this. Do not add large agent-specific full-screen fixture suites for routine tuning; keep Rust tests focused on manifest parsing, rule semantics, source precedence, reload, and update behavior. +Detection fixes that apply equally to Herdr belong upstream. Keep `distribution/agent-detection/` aligned with the bundled manifests; `scripts/agent_detection_manifest_check.py` enforces this. + +Unit-test the detection engine, not individual CLI agents' screen or title conventions. Use synthetic manifests and minimal input strings to test parsing, regions, matching, AND/OR/NOT gates, rule priority, skip-state semantics, source precedence, cache reload behavior, and update flow. Keep bundled-manifest schema validation, process identification, and integration hook/protocol tests. Do not add tests that classify captured or invented CLI screens against bundled agent rules, or freeze an agent's specific detection rule IDs and priorities. + +Validate agent-specific detection behavior with live smoke tests through the manifest hot-reload loop above. Exercise the changed state and nearby transitions (idle, working, blocked, and background work where supported), including relevant optional OSC settings. Record the CLI version, observed signals, and outcomes. Passing engine tests proves the rules execute as written; it does not prove compatibility with the current CLI. ## Vendored libghostty-vt diff --git a/CHANGELOG.md b/CHANGELOG.md index bd763a79..410c6d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog -Superherdr's release history. Superherdr is a fork of [Herdr](https://github.com/herdrdev/herdr); changes inherited from upstream are described in [Herdr's releases](https://github.com/herdrdev/herdr/releases). +Superherdr's release history. Superherdr is a fork of [Herdr](https://github.com/herdrdev/herdr); changes inherited from upstream are described in Herdr's releases and ported into the inherited sections at the end of this file. + +## [0.9.3.1] - Unreleased + +Based on Herdr 0.9.3. Upstream's 0.9.2 and 0.9.3 release bodies are ported into the inherited sections at the end of this file. + +### Added +- The global menu's "what's new" entry is now always available and opens the full merged release history: released Superherdr sections first, then the inherited Herdr sections, rendered from this changelog with no network or filesystem access. Startup, product announcements, and update-preview behavior are unchanged. + +### Changed +- Focus, Snooze, and the Top-level Agents view continue to work across the upstream changes, including the new aggregate-navigation indexing and plugin `min_herdr_version` validation against the real Herdr version (the fork's pinned `HERDR_PLUGIN_COMPATIBILITY_VERSION` constant is gone, matching upstream). +- Upstream's release, preview, website, and Windows cross-compile tooling remains excluded from the fork as before. ## [0.9.1.2] - 2026-09-21 @@ -10,7 +21,7 @@ Superherdr's release history. Superherdr is a fork of [Herdr](https://github.com ## [0.9.1.1] - 2026-09-18 -Based on Herdr 0.9.1. All changes inherited from upstream are listed in [Herdr's 0.9.1 release notes](https://github.com/herdrdev/herdr/releases/tag/v0.9.1), including saved-machine CLI control (`herdr --machine`), cursor-aware text editing, Ctrl-click link highlighting, large SSH bandwidth and idle-CPU reductions, and the full fixes list. +Based on Herdr 0.9.1. Inherited upstream changes are described in the inherited sections below. - Focus, Snooze, and the Top-level Agents view continue to work across the new upstream agent-view projection, per-machine workspace navigation, and collapsed-group changes. - The vendored libghostty-vt build now requires Zig 0.16.0 (inherited from upstream; CI builds with it). @@ -32,3 +43,121 @@ First Superherdr release, based on Herdr 0.9.0. Superherdr versions are the Herd - Binary self-update is disabled. - New worktrees default to `~/.herdr/worktrees`, as in Herdr. - Connecting to a remote host finds and installs `herdr` in the same places Herdr does. Superherdr does not download release binaries for remote hosts; set `HERDR_REMOTE_BINARY` or install herdr there. + +### Inherited from Herdr 0.9.3 + +This is a hotfix release for v0.9.2. See the v0.9.2 notes for the full feature release: https://github.com/herdrdev/herdr/releases/tag/v0.9.2 + +Fixed: +- Terminal shortcuts that send Escape followed by a key work again in panes. On macOS, Option+Left/Right and Option+Backspace from Ghostty's defaults or iTerm2's Natural Text Editing preset move and delete by word again, instead of typing `b` and `f` or deleting one character. Escape-based Shift+Enter bindings insert a newline in Claude Code instead of submitting. Clicking a pane still doesn't send a stray Escape. (#4751) +- Alt+[ followed quickly by another key no longer merges into a different key. (#4751) + +### Inherited from Herdr 0.9.2 + +Breaking changes: +- The Herdr-specific pane graphics API is gone. `pane.graphics.info`, `set`, `clear`, and `stream` now return `unknown_method`. Apps show images by writing standard Kitty graphics to their terminal, which Herdr renders natively. (#4561) + +Added: +- Agents can report their own resume command. Herdr then reopens their exact session after a server restart, with no built-in integration needed. Self-reported agents are also cleared once their pane is back at an idle shell. The new Add Herdr support to your agent guide covers state, resume, and release for agent authors. (#4687) +- Use more than one prefix key: `prefix = ["ctrl+space", "ctrl+s"]`. Every entry enters the same prefix mode, and `prefix+?` lists them all. (#4653, thanks @JJLiebig) +- Go To shows every agent and terminal as its own row, grouped by workspace, with its status and path. Left and Right jump between workspaces. (#4384) +- Bind `keys.clear_pane` to clear the focused pane's screen and scrollback while keeping the current prompt line. It is unbound by default and leaves full-screen apps such as Vim alone. (#4383) +- `herdr machine status` checks saved machines without prompting. `herdr machine reconnect` finishes SSH authentication, including MFA, in your terminal. Open clients recheck failed machines every 30 seconds, so no restart is needed after fixing a connection. (#3763) +- Interactive `machine add` finds the Herdr sessions already running on the host and lets you pick one. `--label` is optional; the machine is named after its SSH host. (#4204, #4678, thanks @JJLiebig and @dhh) +- `terminal session control` accepts `terminal.mouse` events, so bridge clients can click buttons in apps that enable mouse reporting. (#4685) +- Restored agents start one at a time, 100 ms apart by default, instead of all at once. Change the spacing with `[session] startup_per_agent_delay_ms`. (#4102, thanks @JJLiebig) + +Changed: +- Images render faster and more reliably. Local Ghostty receives image data through temporary files instead of terminal output. Popups and notifications crop images around themselves instead of hiding them. Images that scroll out of view stay loaded, so scrolling back no longer resends them. (#4561, #4652, #4686) +- SSH connections request compression. On slow links, remote panes catch up after scrolling in a fraction of the time. (#4340) +- Scrolling output on saved machines sends only the rows that changed, cutting bandwidth for build logs and streaming agents. Older clients and servers keep working with the previous format. (#4711) +- New panes set `TERM_PROGRAM=herdr` and `TERM_PROGRAM_VERSION`. They no longer inherit terminal session IDs, such as iTerm2's, or Claude Code session markers from the terminal that started the server. (#4104) +- Windows panes default to PowerShell 7 (`pwsh`) when it is installed, falling back to Windows PowerShell. An explicit `default_shell` still wins. (#4297, thanks @JJLiebig) +- Closing the last tab of a workspace now asks for confirmation. (#4379, #4409, thanks @minatoaquaMK2) +- Event subscriptions deliver bursts in full. A reader that falls too far behind now gets an `events_lost` error instead of silently skipping events. (#4178, #4225, thanks @minatoaquaMK2) +- On Unix, sending `SIGWINCH` to a Herdr client re-reads the host terminal's colors, so theme switchers that change colors without a light/dark notification can refresh panes. (#4349, thanks @dhh) + +Fixed: +- Saved layouts survive host shutdown and failed restores. On Linux with logind, Herdr saves before the system shuts down. Every platform keeps up to 48 layout snapshots in `session-snapshots/` for manual recovery. (#4320) +- Clicking a pane no longer sends a stray Escape that interrupts a working agent. (#3480) +- An agent's first task now counts as done, even when it started with a prompt. Startup, restored sessions, and Pi's `/new` no longer fire false done notifications. Hook-reported status survives live handoff. (#3338, #3990, #3916) +- The server uses far less CPU with many populated panes. The navigator, pane splits, and named targets stay fast in large sessions, and bursts of external events no longer stall rendering. (#4506, #4546, #4426, #4669, #4670, #4671, #4672, thanks @JJLiebig and @minatoaquaMK2) +- Apps that use synchronized output no longer show torn frames while you type. The cursor no longer flickers during status redraws or jumps around an idle Codex on Windows. (#2968, #4303, thanks @JJLiebig) +- The API socket keeps accepting connections after a transient error, so CLI commands and live handoff no longer fail while the server keeps running. (#4601) +- Slow Git operations during worktree lookups no longer freeze typing. (#4492, thanks @JJLiebig) +- `worktree open` no longer takes over the repository's own workspace, so removing the worktree no longer closes it. (#4293) +- Forwarded SSH agents keep working in remote panes after a reconnect. Repeated `--machine` commands open far fewer SSH connections. (#1931, #4252) +- OpenCode V1 panes stay working or blocked while their subagents run. An existing registration in `tui.json` is respected instead of regenerating `tui.jsonc`. (#1362, #4240) +- Codex is no longer reported idle during active output, its mention popups are recognized, and remapped interrupt keys no longer break detection. (#4507, #4196, thanks @JJLiebig and @unmanbearpig) +- Kiro approval prompts are reported as blocked. Grok idle panes settle with custom or disabled OSC titles. (#4203, #4372, #4333, thanks @vinayshah1998) +- Droid's scrollback clears are honored, so its welcome header and old transcript no longer repeat. (#4432, thanks @factory-ain3sh) +- Copy mode stays active while output continues and a movement key is held. (#3812, #4281, thanks @marcomayer) +- Ctrl+Shift+letter keeps Shift in panes that have not enabled enhanced keyboard input, so Ctrl+Shift+C no longer arrives as Ctrl+C. (#4581, #4597, thanks @factory-ain3sh) +- Mouse reports split across reads no longer leak into the shell after a focus switch over SSH. (#4630) +- Windows: multi-line pastes into Claude Code no longer submit at the first newline, and Shift+Enter is preserved. Mouse capture survives focus changes and resizes, Ctrl+Win no longer opens prefix mode with a `ctrl+space` prefix, and remote clipboard image paste works again. (#4251, #4284, #4470, #4314, thanks @JJLiebig) +- Windows: npm-installed OMP and agents launched through hardened runtimes are detected. `machine add` against a Windows host no longer fails as "not ready for saved machines." (#4313, #4579, #4309, thanks @JJLiebig) +- The navigate-mode workspace highlight is visible with `theme = "terminal"`. Session navigator search matches words independently. The sidebar reveals agents selected by cycling or shortcuts. (#4300, #4408, #4273, #4535, #4355, thanks @minatoaquaMK2) +- `session attach` without a terminal explains the problem instead of leaving a new session running. (#4393) +- Socket error responses keep the original request ID, including subscription setup errors. (#4344) +- `plugin install` accepts options before the repository. (#4446) +- Windows panes pick up the host terminal's light or dark colors from the start, so apps no longer default to dark mode inside a light terminal. (#1530, #4369) +- After `herdr update`, Herdr lists which running servers are still on the old version, with the exact commands to restart each one, and reminds you to update your saved SSH machines. + +### Inherited from Herdr 0.9.1 + +Added: +- Control agents, panes, workspaces, and worktrees on saved SSH machines with `herdr --machine `. Commands use the saved machine's session without needing an open Herdr window. Update Herdr on both machines to use CLI forwarding; failed remote commands never fall back to Local. (#3918) +- Connect to Windows SSH hosts from Linux, macOS, or Windows. Interactive setup can install or update the complete Windows package after confirmation; background reconnects never install updates. (#3651, #3701, #3661, #3687, thanks @JJLiebig) +- Edit names, filters, and search text at the cursor instead of only at the end. Herdr inputs now support character and word movement, Home/End, deletion, and familiar Ctrl+A/E/K/U/W/Y shortcuts, including Unicode text. (#1803, #3698, thanks @markjaquith) +- Added Letta Code detection and native conversation restore, including default conversations. Its experimental integration is installed through `herdr integration install letta`, not the Settings integration list or JSON integration API. (#3106, #3107, thanks @just-cameron) +- Hold Ctrl over a link to highlight it before opening it. Wrapped URLs and OSC 8 links remain clickable even when their beginning or end is outside the viewport. (#1282) +- Sidebar token rules can now hide matching tokens and their separators with `hide = true`; rows disappear when no tokens remain. (#3925) + +Changed: +- Workspace keyboard navigation now spans connected machines in sidebar order, with a visible highlight before Enter switches machines. Collapsed machine and worktree groups remain navigable without accidentally sending actions to the wrong machine. (#3754) +- Experimental live handoff can now transfer more than 64 panes when the sending server includes this update. An older running server still has its old limit for the first upgrade; this does not change update installation order or make live handoff non-experimental. (#3393, #3411, thanks @kataokatsuki) +- On Linux and macOS, terminal observers that stop accepting output are disconnected after 30 seconds without write progress. Their pane and other clients keep running; an interrupted stream may end without a final close record. (#3612) +- Windows terminal cleanup now explicitly resets mouse-reporting modes. The remaining standalone Git Bash detach report in #3748 is still under investigation; do not treat this release as a confirmed fix for that report. (#4055, thanks @JJLiebig) + +Fixed: +- Remote typing, switching, and popup interaction no longer resend the entire pane screen for small changes. Busy SSH sessions use less bandwidth, and idle attached clients avoid unnecessary redraw work. (#3769, #3745, #3822) +- A stalled SSH machine no longer traps the client away from Local. Clicking a local workspace or agent cancels the unfinished remote switch, and a recovered remote workspace refreshes without an away-and-back selection. Stale screens from before a reconnect are not reused. (#3903, #3842) +- Idle SSH connections use less CPU without losing final output. Repeated connection failures back off instead of reconnecting rapidly, and supported idle bridges clean up without stopping remote panes. (#3728, #4083) +- Large pastes no longer disconnect the client with an output-queue error. (#3833) +- SSH setup now shows authentication instructions and useful errors, including Tailscale login URLs and host-key failures, rather than appearing to hang or only reporting a lost connection. `machine add` also accepts options before the SSH target. (#3606, #3731, #3883) +- Machine and worktree groups collapse correctly without changing the selected workspace. The sidebar respects workspace row gaps, stays stable during resizing, and reveals newly focused workspaces. (#3778, #3738, #3817) +- The agent list keeps its scroll position when switching machines. Current-workspace and current-tab filters distinguish identical IDs on different machines. (#3937, #4101, #3732, thanks @minatoaquaMK2) +- Background machine activation no longer resizes another client's focused pane. Window titles follow each client's own view rather than another client's selection. (#3744, #4091, #4130, thanks @JJLiebig) +- `agent focus` and `pane move --focus` now move attached clients to the requested pane. Moving without focus no longer leaves a client following a deleted tab or later unrelated focus changes. (#3760, #4153, #4171, thanks @minatoaquaMK2) +- Creating a worktree focuses its new workspace again. Partially failed worktree removals can be retried, and worktrees with submodules offer an explicit forced-removal choice rather than failing without a recovery action. (#3766, #3314, #1797) +- Temporary worktree-action error banners expire instead of remaining on screen indefinitely. (#1827, #4063, thanks @JJLiebig) +- The last tab remains fully visible when the tab strip overflows, and the session navigator once again draws connected tree branches. (#4151, #3843) +- Muted sidebar and tab labels remain readable on dark themes, including Windows Terminal. (#2692, #4062, thanks @JJLiebig) +- Mouse drag and copy-mode selections stay active while output continues, even when part of the selection is outside the viewport. Copying uses the current text in the selected range. Selection repainting also avoids building up a backlog of mouse movements. (#3841, #3895) +- Double-click selections remain visible with automatic copy disabled. Holding the second click and dragging extends by whole words, and token selection stops at CJK punctuation. Host selection colors remain visible on transparent backgrounds. (#3847, #1836, #3706, #3798) +- Scrolling no longer combines mismatched terminal snapshots or leaves stale wrapped rows in fullscreen applications such as Neovim. Alternate-screen history reads preserve content across changing headers and scrollbars. (#3900, #3329, #3979) +- Local Kitty graphics keep their direct transport after the first image. Popups and notifications hide only overlapping image placements; uncovered images remain visible. (#3785, #4081) +- Pixel mouse coordinates work independently of image rendering and use the terminal's integer cell size, preventing clicks from drifting across the screen. (#3295, #4140, thanks @JJLiebig) +- Function keys F1–F12 are recognized in Kitty keyboard reports, and F1–F4 also work with parameterized reports from terminals such as foot. (#1809, #2378, #4067, thanks @ashokDevs and @JJLiebig) +- WezTerm control-key reports now preserve Enter, Backspace, Tab, and Escape. Cmd/Super-modified input reaches pane applications with its modifier intact. Modified Enter stays compatible with panes that have not requested enhanced key encoding. (#3589, #3710, #4103, #4110, thanks @minatoaquaMK2) +- Mouse reports split across delayed reads no longer leak their trailing bytes into pane input. Clients reassert mouse reporting after terminal reconnects. (#3911, #4079, thanks @JJLiebig) +- Saved sessions that cannot be loaded are preserved before replacement, rather than silently overwritten. Recovery copies are kept in `session-backups/`; failed preservation leaves the original untouched. (#4125) +- Deleting a session now requires its exact name, including case, on case-insensitive filesystems. Names beginning with a hyphen can be passed after `--`. (#3819, #4233, #3220, thanks @minatoaquaMK2) +- Newly started macOS servers retain access to user lookup and DNS across logout. Existing affected servers need a full session restart; live handoff cannot repair their inherited service context. (#4100) +- Linux process discovery stays responsive around stuck WSL agents and avoids repeatedly scanning an ever-growing process tree. (#2179, #3621, #3674, thanks @dark2momo and @caner-akca) +- Linux desktop notifications identify Herdr as their source. (#3638) +- `pane split` without a target now splits the calling pane when invoked inside Herdr, rather than another client's focused pane. Explicit targets keep their existing behavior. (#4123) +- Live agent names survive temporary process-detection uncertainty instead of leaving an active agent unreachable by name. (#3225, #3574, thanks @caner-akca) +- Windows Codex prompts reliably submit after pasted text instead of losing Enter, including long prompts. (#3187, #3961, thanks @JJLiebig) +- OpenCode V2 reports lifecycle state from the pane displaying the conversation, so shared servers do not mix up panes. Completed and interrupted turns return to idle, while pending permissions, forms, and failed executions remain blocked. V1 remains supported; restart OpenCode after installing the updated integration. (#3757, thanks @markjaquith) +- Codex stays working with static titles, animations disabled, and queued follow-ups. Composer sparkles and old confirmation text no longer make an idle pane appear blocked. (#4092, #4099, #3988, thanks @minatoaquaMK2) +- Claude Code's Unicode activity spinner and Pi's working border are recognized correctly. Cline launcher detection and idle-state reporting are more reliable. (#3949, #3629, #2396) +- Pi records native session paths on Windows. Nested OMP processes no longer replace the parent pane's resumable session, and Windows Kimi npm shims are recognized. (#3726, #2593, #3994, #3317, #4053, thanks @JJLiebig) +- Grok updates its restore reference after `/new`, and its imported Claude-compatible hooks no longer overwrite that reference. Reinstall the Claude integration to adopt the narrower hook matching. (#2681, #4018) +- Integration configuration updates preserve existing files when writes fail. Hermes installation also preserves valid YAML when enabled plugins use an inline list. (#3970, #3839, #4056, thanks @JJLiebig) +- Plugin registry writes preserve symlinks. Plugins keep using the running server's compatible binary after a client-only update, and manual pane navigation emits focus events again. (#3922, #3824, #4077) +- Config files containing a UTF-8 BOM load and save correctly. The default config places `ui.accent` in the right table. (#3678, #3962, #2697, thanks @JJLiebig) +- Windows local and SSH input preserves Alt combinations, Ctrl+S, and enhanced keyboard text instead of dropping keys or inserting garbage. Remote dead-key input no longer adds an extra base character. (#3702, #3910, #3932, #3948, #4133, #4148, thanks @JJLiebig) +- Windows mouse capture preserves SGR coordinates, including past column 95, and reattaching restores mouse reporting. Legacy SSH mouse reports no longer produce phantom characters or block subsequent input. (#4155, #3735, #4080, thanks @JJLiebig) +- Git Bash SSH connections honor host aliases from the user's SSH config. (#3947, #4054, thanks @JJLiebig) +- Windows login-mode panes honor the configured shell and keep PowerShell working-directory reports current. Pane launch paths use consistent directory casing. (#1445, #4060, #4065, #4199, thanks @JJLiebig) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c31f3043..fe9a3c54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,8 @@ Superherdr is a fork of [Herdr](https://github.com/herdrdev/herdr), a terminal r Most of Superherdr's runtime comes from Herdr, and upstream releases are merged regularly. If a bug also exists in Herdr and is not caused by Superherdr's changes, consider reporting it [upstream](https://github.com/herdrdev/herdr) so both projects benefit. Superherdr-specific behavior (Focus, Snooze, the Top-level Agents view, installation, and compatibility rules) belongs here. See the [behavior baseline](openspec/README.md). +Every upstream sync must graft the merged release bodies into `CHANGELOG.md` as `### Inherited from Herdr ` sections at the end of the file, newest first. The What's New view renders this file in installed builds, so a skipped graft leaves upgraded installs without the new release's notes. Keep each section free of `##`-level headings, `####` headings, emphasis markers, and markdown links: the release-notes renderer styles only `###` headings and renders everything else literally. The full-history tests fail when the newest inherited section is missing, which is the reminder for this step. + ## Choose the contribution path Search existing issues, pull requests, and [specifications](openspec/specs/) first. Link related work instead of opening duplicates. diff --git a/Cargo.lock b/Cargo.lock index 8490963d..28d96412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,40 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "atomic" version = "0.6.1" @@ -425,6 +459,33 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -450,6 +511,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -460,6 +541,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fdeflate" version = "0.3.7" @@ -568,6 +655,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -643,6 +743,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghostty-vt" +version = "0.0.0" +dependencies = [ + "base64", + "libc", + "png", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1037,6 +1147,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1215,6 +1341,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1544,6 +1679,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1645,6 +1791,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -1680,7 +1836,7 @@ dependencies = [ [[package]] name = "superherdr" -version = "0.9.1" +version = "0.9.3" dependencies = [ "base64", "bincode", @@ -1689,6 +1845,8 @@ dependencies = [ "clap_complete", "crossterm", "ctrlc", + "futures-util", + "ghostty-vt", "interprocess", "jsonc-parser", "libc", @@ -1712,6 +1870,7 @@ dependencies = [ "widestring", "windows-sys", "wmi", + "zbus", ] [[package]] @@ -1736,6 +1895,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -1892,7 +2075,9 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", + "tracing", "windows-sys", ] @@ -1915,8 +2100,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -1928,6 +2113,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -1937,9 +2131,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -2021,6 +2236,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2077,6 +2303,7 @@ dependencies = [ "atomic", "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2438,6 +2665,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -2549,8 +2785,114 @@ dependencies = [ "windows-core", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index c8383750..637fc706 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "superherdr" # Herdr base version; the Superherdr revision lives in src/build_info.rs. -version = "0.9.1" +version = "0.9.3" edition = "2021" build = "build.rs" description = "Superherdr: a terminal project built on Herdr with Focus and Snooze" @@ -11,6 +11,7 @@ categories = ["command-line-utilities"] include = [ "src/**/*", "assets/sounds/*", + "CHANGELOG.md", "docs/next/api/herdr-api.schema.json", "skills/herdr/SKILL.md", "distribution/install.ps1", @@ -23,7 +24,12 @@ include = [ name = "herdr" path = "src/main.rs" +[workspace] +members = ["crates/ghostty-vt"] +default-members = [".", "crates/ghostty-vt"] + [dependencies] +ghostty-vt = { path = "crates/ghostty-vt" } base64 = "0.22.1" bincode = { version = "2", features = ["serde"] } bytes = "1" @@ -55,6 +61,11 @@ uuid = { version = "1.22.0", features = ["v4"] } [patch.crates-io] portable-pty = { path = "vendor/portable-pty" } +[target.'cfg(target_os = "linux")'.dependencies] +# logind's delay inhibitor lets the server save before host shutdown kills panes. +zbus = { version = "5.19", default-features = false, features = ["tokio"] } +futures-util = { version = "0.3", default-features = false, features = ["std"] } + [target.'cfg(windows)'.dependencies] # UTF-16 SDDL input for interprocess security descriptors. widestring = "1.2" @@ -74,6 +85,7 @@ windows-sys = { version = "0.61.2", features = [ "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Pipes", + "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", diff --git a/README.md b/README.md index b8f9e16b..16cf8f4a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ herdr --skill herdr ``` -`ctrl+b q` detaches the client. Running the same command reattaches. Focus and Snooze are available in the sidebar and workspace/project context menus. +`ctrl+b q` detaches the client. Running the same command reattaches. Focus and Snooze are available in the sidebar and workspace/project context menus. The global menu's "what's new" entry always opens the full merged release history — Superherdr releases first, then the inherited Herdr releases — rendered from the changelog embedded in the binary, with no network access. For SSH, `herdr --remote ` uses a compatible `herdr` on the remote host, whether Herdr or Superherdr; Snooze needs Superherdr on the remote. If none is installed, it offers to install your local binary, or the build named by `HERDR_REMOTE_BINARY`, at `~/.local/bin/herdr`. It never downloads release binaries. diff --git a/build.rs b/build.rs index 870db183..0934d2f3 100644 --- a/build.rs +++ b/build.rs @@ -1,115 +1,6 @@ -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process::Command; - -fn zig_target(target: &str) -> &str { - match target { - "x86_64-unknown-linux-gnu" => "x86_64-linux-gnu", - "aarch64-unknown-linux-gnu" => "aarch64-linux-gnu", - "x86_64-unknown-linux-musl" => "x86_64-linux-musl", - "aarch64-unknown-linux-musl" => "aarch64-linux-musl", - "x86_64-apple-darwin" => "x86_64-macos", - "aarch64-apple-darwin" => "aarch64-macos", - "x86_64-pc-windows-msvc" => "x86_64-windows-msvc", - "aarch64-pc-windows-msvc" => "aarch64-windows-msvc", - other => panic!("unsupported target for libghostty-vt build: {other}"), - } -} - -fn env_bool(name: &str) -> Option { - match env::var(name) { - Ok(value) => match value.to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Some(true), - "0" | "false" | "no" | "off" => Some(false), - other => panic!("invalid boolean value for {name}: {other}"), - }, - Err(env::VarError::NotPresent) => None, - Err(err) => panic!("failed to read {name}: {err}"), - } -} - fn main() { println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=vendor/libghostty-vt.vendor.json"); - println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig"); - println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig.zon"); - println!("cargo:rerun-if-changed=vendor/libghostty-vt/include"); - println!("cargo:rerun-if-changed=vendor/libghostty-vt/pkg"); - println!("cargo:rerun-if-changed=vendor/libghostty-vt/src"); - println!("cargo:rerun-if-changed=vendor/libghostty-vt/VERSION"); - println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_OPTIMIZE"); - println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_SIMD"); - println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_ZIG_SYSTEM_DIR"); println!("cargo:rerun-if-env-changed=HERDR_BUILD_CHANNEL"); println!("cargo:rerun-if-env-changed=HERDR_BUILD_ID"); println!("cargo:rerun-if-env-changed=HERDR_BUILD_COMMIT"); - println!("cargo:rerun-if-env-changed=ZIG"); - println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_WINDOWS_LIBC"); - - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); - let vendored_dir = manifest_dir.join("vendor/libghostty-vt"); - let optimize = env::var("LIBGHOSTTY_VT_OPTIMIZE").unwrap_or_else(|_| "ReleaseFast".into()); - let simd = env_bool("LIBGHOSTTY_VT_SIMD").unwrap_or(true); - let target = env::var("TARGET").expect("TARGET"); - let zig_target = zig_target(&target); - let version_string = fs::read_to_string(vendored_dir.join("VERSION")) - .expect("failed to read vendored libghostty-vt VERSION") - .trim() - .to_string(); - - let zig = env::var("ZIG").unwrap_or_else(|_| "zig".into()); - let mut command = Command::new(&zig); - command - .arg("build") - .arg("-Demit-lib-vt") - .arg(format!("-Doptimize={optimize}")) - .arg(format!("-Dsimd={simd}")) - .arg(format!("-Dtarget={zig_target}")) - .arg(format!("-Dversion-string={version_string}")) - .arg("-Demit-xcframework=false"); - if target.ends_with("windows-msvc") { - if let Some(libc_file) = env::var_os("LIBGHOSTTY_VT_WINDOWS_LIBC") { - println!( - "cargo:rerun-if-changed={}", - PathBuf::from(&libc_file).display() - ); - command.arg("--libc").arg(libc_file); - } - } - if let Ok(system_dir) = env::var("LIBGHOSTTY_VT_ZIG_SYSTEM_DIR") { - command.arg("--system").arg(system_dir); - } - - let status = command - .current_dir(&vendored_dir) - .status() - .unwrap_or_else(|err| { - if err.kind() == std::io::ErrorKind::NotFound { - panic!( - "zig executable not found (looked for {zig:?}; set the ZIG \ - environment variable to point at the zig binary). Building \ - the vendored libghostty-vt requires Zig 0.16.0: install it from \ - https://ziglang.org/download/, then retry the build" - ); - } - panic!("failed to execute zig build for vendored libghostty-vt: {err}"); - }); - assert!( - status.success(), - "zig build for vendored libghostty-vt failed: {status}. \ - Building Herdr requires Zig 0.16.0; check `zig version` \ - or set ZIG to the path of a Zig 0.16.0 binary, then retry" - ); - - let lib_dir = vendored_dir.join("zig-out/lib"); - println!("cargo:rustc-link-search=native={}", lib_dir.display()); - if target.contains("apple-darwin") { - let static_lib = lib_dir.join("libghostty-vt.a"); - println!("cargo:rustc-link-arg={}", static_lib.display()); - } else if target.contains("windows-msvc") { - println!("cargo:rustc-link-lib=static=ghostty-vt-static"); - } else { - println!("cargo:rustc-link-lib=static=ghostty-vt"); - } } diff --git a/crates/ghostty-vt/Cargo.toml b/crates/ghostty-vt/Cargo.toml new file mode 100644 index 00000000..53c44fda --- /dev/null +++ b/crates/ghostty-vt/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ghostty-vt" +version = "0.0.0" +edition = "2021" +description = "Herdr's binding to its vendored libghostty-vt" +license = "Apache-2.0" +repository = "https://github.com/herdrdev/herdr" +publish = false + +[dependencies] +base64 = "0.22.1" +libc = "0.2" +png = "0.17" +tracing = "0.1.44" diff --git a/crates/ghostty-vt/build.rs b/crates/ghostty-vt/build.rs new file mode 100644 index 00000000..af02e0a7 --- /dev/null +++ b/crates/ghostty-vt/build.rs @@ -0,0 +1,137 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn zig_target(target: &str) -> &str { + match target { + "x86_64-unknown-linux-gnu" => "x86_64-linux-gnu", + "aarch64-unknown-linux-gnu" => "aarch64-linux-gnu", + "x86_64-unknown-linux-musl" => "x86_64-linux-musl", + "aarch64-unknown-linux-musl" => "aarch64-linux-musl", + "x86_64-apple-darwin" => "x86_64-macos", + "aarch64-apple-darwin" => "aarch64-macos", + "aarch64-apple-ios" => "aarch64-ios", + "aarch64-apple-ios-sim" => "aarch64-ios-simulator", + "x86_64-pc-windows-msvc" => "x86_64-windows-msvc", + "aarch64-pc-windows-msvc" => "aarch64-windows-msvc", + other => panic!("unsupported target for libghostty-vt build: {other}"), + } +} + +fn env_bool(name: &str) -> Option { + match env::var(name) { + Ok(value) => match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + other => panic!("invalid boolean value for {name}: {other}"), + }, + Err(env::VarError::NotPresent) => None, + Err(err) => panic!("failed to read {name}: {err}"), + } +} + +fn main() { + // The vendored source stays at the repository root, shared with its maintenance scripts. + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let vendor_dir = manifest_dir.join("../../vendor"); + let vendored_dir = vendor_dir.join("libghostty-vt"); + + println!("cargo:rerun-if-changed=build.rs"); + for path in [ + vendor_dir.join("libghostty-vt.vendor.json"), + vendored_dir.join("build.zig"), + vendored_dir.join("build.zig.zon"), + vendored_dir.join("include"), + vendored_dir.join("pkg"), + vendored_dir.join("src"), + vendored_dir.join("VERSION"), + ] { + println!("cargo:rerun-if-changed={}", path.display()); + } + println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_OPTIMIZE"); + println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_SIMD"); + println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_ZIG_SYSTEM_DIR"); + println!("cargo:rerun-if-env-changed=ZIG"); + println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_WINDOWS_LIBC"); + + let optimize = env::var("LIBGHOSTTY_VT_OPTIMIZE").unwrap_or_else(|_| "ReleaseFast".into()); + let simd = env_bool("LIBGHOSTTY_VT_SIMD").unwrap_or(true); + let target = env::var("TARGET").expect("TARGET"); + let zig_target = zig_target(&target); + let version_string = fs::read_to_string(vendored_dir.join("VERSION")) + .expect("failed to read vendored libghostty-vt VERSION") + .trim() + .to_string(); + + // Install into this build's OUT_DIR so concurrent builds for different targets + // from one checkout never link each other's archive. + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let zig_prefix = out_dir.join("zig-out"); + + let zig = env::var("ZIG").unwrap_or_else(|_| "zig".into()); + let mut command = Command::new(&zig); + command + .arg("build") + .arg("--prefix") + .arg(&zig_prefix) + .arg("-Demit-lib-vt") + .arg(format!("-Doptimize={optimize}")) + .arg(format!("-Dsimd={simd}")) + .arg(format!("-Dtarget={zig_target}")) + .arg(format!("-Dversion-string={version_string}")) + .arg("-Demit-xcframework=false"); + if target.ends_with("windows-msvc") { + if let Some(libc_file) = env::var_os("LIBGHOSTTY_VT_WINDOWS_LIBC") { + println!( + "cargo:rerun-if-changed={}", + PathBuf::from(&libc_file).display() + ); + command.arg("--libc").arg(libc_file); + } + } + if let Ok(system_dir) = env::var("LIBGHOSTTY_VT_ZIG_SYSTEM_DIR") { + command.arg("--system").arg(system_dir); + } + + let status = command + .current_dir(&vendored_dir) + .status() + .unwrap_or_else(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + panic!( + "zig executable not found (looked for {zig:?}; set the ZIG \ + environment variable to point at the zig binary). Building \ + the vendored libghostty-vt requires Zig 0.16.0: install it from \ + https://ziglang.org/download/, then retry the build" + ); + } + panic!("failed to execute zig build for vendored libghostty-vt: {err}"); + }); + assert!( + status.success(), + "zig build for vendored libghostty-vt failed: {status}. \ + Building Herdr requires Zig 0.16.0; check `zig version` \ + or set ZIG to the path of a Zig 0.16.0 binary, then retry" + ); + + let mut lib_dir = zig_prefix.join("lib"); + if target.contains("-apple-") { + // Apple's linker prefers the sibling dylib for `-l`, so search a directory + // holding only the static archive. + let static_dir = out_dir.join("lib"); + fs::create_dir_all(&static_dir).expect("failed to create libghostty-vt link directory"); + fs::copy( + lib_dir.join("libghostty-vt.a"), + static_dir.join("libghostty-vt.a"), + ) + .expect("failed to copy libghostty-vt static archive"); + lib_dir = static_dir; + } + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + if target.contains("windows-msvc") { + println!("cargo:rustc-link-lib=static=ghostty-vt-static"); + } else { + println!("cargo:rustc-link-lib=static=ghostty-vt"); + } +} diff --git a/src/ghostty/bindings.rs b/crates/ghostty-vt/src/bindings.rs similarity index 98% rename from src/ghostty/bindings.rs rename to crates/ghostty-vt/src/bindings.rs index bfa71825..33443aca 100644 --- a/src/ghostty/bindings.rs +++ b/crates/ghostty-vt/src/bindings.rs @@ -2069,16 +2069,28 @@ pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FORMAT: #[doc = " Compression of the image. Always\n GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are\n inflated before storage.\n\n Output type: GhosttyKittyImageCompression *"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION: GhosttyKittyGraphicsImageData = 6; -#[doc = " Borrowed pointer to the raw pixel data. Valid as long as the\n underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when\n the image metadata is resident but its pixel payload is pending.\n\n The data is always fully decoded, uncompressed pixels in the\n format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads\n are inflated and PNG payloads are decoded to RGBA at transmission\n time, before the image is stored. Consumers can upload this\n directly to the GPU without any decode step.\n\n For an animated image (Kitty graphics animation, actions a=f/a=a)\n this is the pixel data of the current animation frame. The\n image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the\n current frame changes, so generation-keyed caches remain\n coherent.\n\n Output type: const uint8_t **"] +#[doc = " Borrowed pointer to the raw pixel data. Valid as long as the\n underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when\n the image metadata is resident but its pixel payload is pending, or\n when the experimental PRESERVE_PNG option retained encoded PNG data.\n\n The data is always fully decoded, uncompressed pixels in the\n format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads\n are inflated and PNG payloads are normally decoded to RGBA at transmission\n time, before the image is stored. Consumers can upload returned pixels\n directly to the GPU without any decode step.\n\n For an animated image (Kitty graphics animation, actions a=f/a=a)\n this is the pixel data of the current animation frame. The\n image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the\n current frame changes, so generation-keyed caches remain\n coherent.\n\n Output type: const uint8_t **"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR: GhosttyKittyGraphicsImageData = 7; -#[doc = " Length of the raw pixel data in bytes. Always equal to\n width * height * bytes-per-pixel for the reported format. For a\n pending image, this is the expected length reserved against the\n storage limit even though DATA_PTR is not available yet.\n\n Output type: size_t *"] +#[doc = " Length of the raw pixel data in bytes. Always equal to\n width * height * bytes-per-pixel for the reported format. For a\n pending or encoded PNG image, this is the expected decoded length reserved against the\n storage limit even though DATA_PTR is not available yet.\n\n Output type: size_t *"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN: GhosttyKittyGraphicsImageData = 8; #[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers. Pending\n payload completion preserves this value to retain image age; consumers\n detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION\n and retry DATA_PTR.\n\n Output type: uint64_t *"] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION: GhosttyKittyGraphicsImageData = 9; -#[doc = " Generation stamp assigned when this image was added to (or\n replaced in) the storage. A changed generation for a given image\n ID means the pixel contents may have changed even when the\n dimensions, format, and data length are identical (e.g. a\n retransmission of the same image ID), so texture caches must key\n staleness on this value rather than on size heuristics.\n\n Stamps are unique and monotonically increasing process-wide and\n are drawn from the same sequence as\n GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored\n image, so zero can be used as an \"empty\" sentinel by callers. Pending\n payload completion preserves this value to retain image age; consumers\n detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION\n and retry DATA_PTR.\n\n Output type: uint64_t *"] +#[doc = " Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE\n unless experimentally retained. Output type: const uint8_t **."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR: + GhosttyKittyGraphicsImageData = 10; +#[doc = " Encoded PNG length; NO_VALUE unless retained. Output type: size_t *."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN: + GhosttyKittyGraphicsImageData = 11; +#[doc = " Borrowed void* backing context; valid only while image is unchanged."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT: + GhosttyKittyGraphicsImageData = 12; +#[doc = " uint64_t immutable revision fingerprint; never reads image pixels."] +pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY: + GhosttyKittyGraphicsImageData = 13; +#[doc = " uint64_t immutable revision fingerprint; never reads image pixels."] pub const GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE: GhosttyKittyGraphicsImageData = 2147483647; #[doc = " Queryable data kinds for ghostty_kitty_graphics_image_get().\n\n @ingroup kitty_graphics"] @@ -2282,6 +2294,76 @@ unsafe extern "C" { out_info: *mut GhosttyKittyGraphicsPlacementRenderInfo, ) -> GhosttyResult; } +#[doc = " Borrowed opened descriptor; size is sizeof this request. No path is supplied."] +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyKittyImageSnapshotFileRequest { + pub size: usize, + pub fd: i64, + pub expected_len: u64, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyKittyImageSnapshotFileRequest"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of GhosttyKittyImageSnapshotFileRequest"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyKittyImageSnapshotFileRequest::size"] + [::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, size) - 0usize]; + ["Offset of field: GhosttyKittyImageSnapshotFileRequest::fd"] + [::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, fd) - 8usize]; + ["Offset of field: GhosttyKittyImageSnapshotFileRequest::expected_len"] + [::std::mem::offset_of!(GhosttyKittyImageSnapshotFileRequest, expected_len) - 16usize]; +}; +pub type GhosttyKittyImageFileReadFn = ::std::option::Option< + unsafe extern "C" fn(context: *mut ::std::os::raw::c_void, dest: *mut u8, len: usize) -> bool, +>; +pub type GhosttyKittyImageFileReleaseFn = + ::std::option::Option; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyKittyImageFileBacking { + pub context: *mut ::std::os::raw::c_void, + pub identity: u64, + pub len: usize, + pub read: GhosttyKittyImageFileReadFn, + pub release: GhosttyKittyImageFileReleaseFn, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyKittyImageFileBacking"] + [::std::mem::size_of::() - 40usize]; + ["Alignment of GhosttyKittyImageFileBacking"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyKittyImageFileBacking::context"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, context) - 0usize]; + ["Offset of field: GhosttyKittyImageFileBacking::identity"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, identity) - 8usize]; + ["Offset of field: GhosttyKittyImageFileBacking::len"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, len) - 16usize]; + ["Offset of field: GhosttyKittyImageFileBacking::read"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, read) - 24usize]; + ["Offset of field: GhosttyKittyImageFileBacking::release"] + [::std::mem::offset_of!(GhosttyKittyImageFileBacking, release) - 32usize]; +}; +impl Default for GhosttyKittyImageFileBacking { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[doc = " Optional synchronous callback, default NULL. Verify exact descriptor size\n and snapshot immutable bytes before returning true. True transfers one context\n reference; false transfers nothing. Do not close the borrowed descriptor or\n reenter the terminal. Read must fill exactly len bytes; release is called once.\n identity identifies the immutable revision, not a pathname or source inode."] +pub type GhosttyKittyImageSnapshotFileFn = ::std::option::Option< + unsafe extern "C" fn( + terminal: GhosttyTerminal, + userdata: *mut ::std::os::raw::c_void, + request: *const GhosttyKittyImageSnapshotFileRequest, + out_backing: *mut GhosttyKittyImageFileBacking, + ) -> bool, +>; #[doc = " Perform one bounded compression step suitable for idle scheduling."] pub const GhosttyTerminalCompressionMode_GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL: GhosttyTerminalCompressionMode = 0; @@ -3119,7 +3201,13 @@ pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ: GhosttyTerm #[doc = " Set the maximum total decoded bytes a single Kitty clipboard protocol\n (OSC 5522) write transaction may accumulate. The limit is captured\n when a transaction begins; an in-flight transaction keeps the limit\n it started with.\n\n Data beyond the limit fails the whole transaction with EFBIG. The\n transaction is discarded, later write-related packets are ignored\n until a new write begins, and nothing reaches the clipboard write\n callback.\n\n Transactions are buffered in memory, so this limit bounds how much\n memory a single write can make the terminal allocate. Pass SIZE_MAX\n to remove the limit. A NULL value pointer reverts to the built-in\n default of 64MiB, the minimum required by the protocol.\n\n This limit doesn't apply to OSC 52 writes, which are bounded by the\n maximum length of an escape sequence instead.\n\n Input type: size_t*"] pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES: GhosttyTerminalOption = 39; -#[doc = " Set the maximum total decoded bytes a single Kitty clipboard protocol\n (OSC 5522) write transaction may accumulate. The limit is captured\n when a transaction begins; an in-flight transaction keeps the limit\n it started with.\n\n Data beyond the limit fails the whole transaction with EFBIG. The\n transaction is discarded, later write-related packets are ignored\n until a new write begins, and nothing reaches the clipboard write\n callback.\n\n Transactions are buffered in memory, so this limit bounds how much\n memory a single write can make the terminal allocate. Pass SIZE_MAX\n to remove the limit. A NULL value pointer reverts to the built-in\n default of 64MiB, the minimum required by the protocol.\n\n This limit doesn't apply to OSC 52 writes, which are bounded by the\n maximum length of an escape sequence instead.\n\n Input type: size_t*"] +#[doc = " Experimental, default-off PNG retention for quiet base transmissions.\n Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only\n be detected when an animation operation materializes the image."] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG: + GhosttyTerminalOption = 40; +#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."] +pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE: + GhosttyTerminalOption = 41; +#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."] pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_MAX_VALUE: GhosttyTerminalOption = 2147483647; #[doc = " Terminal option identifiers.\n\n These values are used with ghostty_terminal_set() to configure\n terminal callbacks and associated state.\n\n @ingroup terminal"] pub type GhosttyTerminalOption = ::std::os::raw::c_int; @@ -3236,6 +3324,10 @@ unsafe extern "C" { #[doc = " Perform a full reset of the terminal (RIS).\n\n Resets all terminal state back to its initial configuration, including\n modes, scrollback, scrolling region, and screen contents. The terminal\n dimensions are preserved.\n\n @param terminal The terminal handle (may be NULL, in which case this is a no-op)\n\n @ingroup terminal"] pub fn ghostty_terminal_reset(terminal: GhosttyTerminal); } +unsafe extern "C" { + #[doc = " Clear screen and history, retaining the cursor's soft-wrapped active line.\n Does not alter the VT parser or write to the child process. Returns false\n without changing the terminal on the alternate screen or for a NULL handle.\n Otherwise returns true and moves the retained line to the top of the screen."] + pub fn ghostty_terminal_clear_screen(terminal: GhosttyTerminal) -> bool; +} unsafe extern "C" { #[doc = " Resize the terminal to the given dimensions.\n\n Changes the number of columns and rows in the terminal. The primary\n screen will reflow content if wraparound mode is enabled; the alternate\n screen does not reflow. If the dimensions are unchanged, this is a no-op.\n\n This also updates the terminal's pixel dimensions (used for image\n protocols and size reports), disables synchronized output mode (allowed\n by the spec so that resize results are shown immediately), and sends an\n in-band size report if mode 2048 is enabled.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param cols New width in cells (must be greater than zero)\n @param rows New height in cells (must be greater than zero)\n @param cell_width_px Width of a single cell in pixels\n @param cell_height_px Height of a single cell in pixels\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup terminal"] pub fn ghostty_terminal_resize( diff --git a/src/ghostty/mod.rs b/crates/ghostty-vt/src/lib.rs similarity index 92% rename from src/ghostty/mod.rs rename to crates/ghostty-vt/src/lib.rs index d1748aad..7ee1e751 100644 --- a/src/ghostty/mod.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -12,7 +12,7 @@ pub mod bindings; use std::cell::Cell; use std::collections::hash_map::DefaultHasher; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::ffi::c_void; use std::fmt; use std::hash::{Hash, Hasher}; @@ -22,7 +22,16 @@ use std::ops::RangeInclusive; use std::os::raw::c_char; use std::ptr; use std::slice; -use std::sync::{Mutex, Once, OnceLock}; +use std::sync::{Arc, Mutex, Once, OnceLock}; + +use crate::pane_graphics_files::OwnedExport; +#[cfg(target_os = "linux")] +mod native_image_sources; +mod native_source; +pub mod pane_graphics_files; + +/// Terminfo entry the terminal emulates; child processes should see it as TERM. +pub const TERM: &str = "xterm-256color"; pub use bindings as ffi; @@ -74,7 +83,7 @@ pub enum Dirty { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TerminalCompressionResult { +pub enum TerminalCompressionResult { Unsupported, Pending, Complete, @@ -196,7 +205,7 @@ const TERMINAL_DATA_COLOR_CURSOR: ffi::GhosttyTerminalData = 20; const KITTY_IMAGE_STORAGE_LIMIT_BYTES: u64 = 64 * 1024 * 1024; const APC_MAX_BYTES: usize = 16 * 1024 * 1024; const APC_MAX_BYTES_KITTY: usize = 16 * 1024 * 1024; -pub(crate) const KITTY_UNICODE_PLACEHOLDER: u32 = 0x10EEEE; +pub const KITTY_UNICODE_PLACEHOLDER: u32 = 0x10EEEE; // The vendored C headers expose these placement fields, but the checked-in // generated bindings predate the names. Keep the explicit values aligned with // vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h. @@ -227,6 +236,7 @@ pub struct KittyImagePlacement { pub data_len: usize, pub data_fingerprint: u64, pub data: Vec, + pub source_file: Option>, pub render: KittyPlacementRenderInfo, } @@ -239,8 +249,11 @@ pub struct KittyImageDescriptor { pub format: KittyImageFormat, pub data_len: usize, pub data_fingerprint: u64, + pub source_file: bool, } +type KittyPlacementPayload = (KittyImageDescriptor, Vec, Option>); + #[derive(Debug, Clone, Copy)] struct KittyImageFingerprintEntry { generation: u64, @@ -456,13 +469,13 @@ pub enum CellWide { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ScreenTextCell { +pub struct ScreenTextCell { pub wide: CellWide, pub graphemes: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ScreenTextRow { +pub struct ScreenTextRow { pub cells: Vec, pub soft_wrapped: bool, pub wrap_continuation: bool, @@ -690,6 +703,16 @@ fn install_png_decoder_once() { }); } +#[cfg(test)] +thread_local! { + static PNG_DECODE_CALLS: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +mod native_source_tests; +#[cfg(test)] +mod png_forward_tests; + unsafe extern "C" fn decode_png_trampoline( _userdata: *mut c_void, allocator: *const ffi::GhosttyAllocator, @@ -701,6 +724,8 @@ unsafe extern "C" fn decode_png_trampoline( return false; } let bytes = unsafe { slice::from_raw_parts(data, data_len) }; + #[cfg(test)] + PNG_DECODE_CALLS.with(|calls| calls.set(calls.get() + 1)); let Some(rgba) = decode_png_rgba(bytes) else { return false; }; @@ -807,11 +832,19 @@ pub fn encode_focus(event: FocusEvent) -> Result, Error> { } #[derive(Debug, PartialEq, Eq)] -pub(crate) enum LinkTarget { +pub enum LinkTarget { Uri(String), Text { text: String, clicked_byte: usize }, } +/// Inclusive display-cell columns on the current viewport. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LinkRegion { + pub row: u16, + pub start_col: u16, + pub end_col: u16, +} + pub struct Terminal { raw: ffi::GhosttyTerminal, max_scrollback: usize, @@ -820,6 +853,7 @@ pub struct Terminal { callback_state: Box, kitty_fingerprints: Mutex>, kitty_empty_generation: Cell>, + kitty_png_forwarding: bool, } impl Terminal { @@ -845,12 +879,13 @@ impl Terminal { }), kitty_fingerprints: Mutex::new(HashMap::new()), kitty_empty_generation: Cell::new(None), + kitty_png_forwarding: false, }; let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast(); let glyph_protocol = false; let terminfo_name = ffi::GhosttyString { - ptr: crate::pane::PANE_TERM.as_ptr().cast(), - len: crate::pane::PANE_TERM.len(), + ptr: TERM.as_ptr().cast(), + len: TERM.len(), }; let grapheme_default = ffi::GhosttyTerminalModeConfig { mode: MODE_GRAPHEME_CLUSTER, @@ -930,7 +965,7 @@ impl Terminal { } } - pub(crate) fn compression_activity(&self) -> Result { + pub fn compression_activity(&self) -> Result { let mut activity = 0; // SAFETY: self.raw is a live terminal handle and activity is a valid out pointer. unsafe { @@ -939,7 +974,7 @@ impl Terminal { Ok(activity) } - pub(crate) fn compress_incremental(&mut self) -> Result { + pub fn compress_incremental(&mut self) -> Result { let mut result = ffi::GhosttyTerminalCompressionResult_GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED; // SAFETY: self.raw is a live terminal handle and result is a valid out pointer. @@ -1072,6 +1107,25 @@ impl Terminal { ) .into_result()?; } + self.set_kitty_source_forwarding(true)?; + Ok(()) + } + + pub fn set_kitty_source_forwarding(&mut self, enabled: bool) -> Result<(), Error> { + native_source::set_forwarding(self.raw, enabled) + } + + #[cfg(test)] + fn set_kitty_png_forwarding(&mut self, enabled: bool) -> Result<(), Error> { + unsafe { + ffi::ghostty_terminal_set( + self.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG, + (&enabled as *const bool).cast(), + ) + .into_result()?; + } + self.kitty_png_forwarding = enabled; Ok(()) } @@ -1200,7 +1254,7 @@ impl Terminal { } #[cfg(windows)] - pub(crate) fn track_row(&mut self, y: u32) -> Option { + pub fn track_row(&mut self, y: u32) -> Option { let mut point = ffi::GhosttyPointCoordinate::default(); let tag = ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN; let result = @@ -1222,11 +1276,11 @@ impl Terminal { Ok((wide, graphemes)) } - pub(crate) fn screen_text_rows(&self) -> Result, Error> { + pub fn screen_text_rows(&self) -> Result, Error> { self.screen_text_rows_range(0, usize::MAX) } - pub(crate) fn screen_text_rows_range( + pub fn screen_text_rows_range( &self, start_row: usize, end_row_exclusive: usize, @@ -1277,7 +1331,7 @@ impl Terminal { grid_ref_hyperlink_uri(&grid_ref) } - pub(crate) fn viewport_link_target(&self, x: u16, y: u32) -> Result, Error> { + pub fn viewport_link_target(&self, x: u16, y: u32) -> Result, Error> { Ok(self .viewport_link_selection(x, y)? .map(|(target, _)| target)) @@ -1350,12 +1404,12 @@ impl Terminal { /// Resolve only the bounded plain-text token. OSC 8 regions are resolved by /// clients from frame hyperlink IDs; their full URI activation path is unchanged. - pub(crate) fn viewport_link_regions( + pub fn viewport_link_regions( &self, x: u16, y: u32, resolve: fn(&str, usize) -> Option>, - ) -> Result, Error> { + ) -> Result, Error> { let cols = self.cols()?; let rows = self.rows()?; if x >= cols || y >= u32::from(rows) { @@ -1421,7 +1475,7 @@ impl Terminal { false, )?; let mut byte = prefix.len().saturating_sub(cell_len(&first)?); - let mut regions: Vec = Vec::new(); + let mut regions: Vec = Vec::new(); for row in start_row..=end_row { let mut cell = self.grid_ref(ghostty_viewport_point(0, row))?; let left = if row == start_row { start_col } else { 0 }; @@ -1445,7 +1499,7 @@ impl Terminal { { last.end_col = end; } else { - regions.push(crate::api::schema::PaneLinkRegion { + regions.push(LinkRegion { row: row as u16, start_col: col, end_col: end, @@ -1619,6 +1673,10 @@ impl Terminal { Ok(text) } + pub fn clear_screen(&mut self) -> bool { + unsafe { ffi::ghostty_terminal_clear_screen(self.raw) } + } + pub fn scroll_viewport_bottom(&mut self) { let viewport = ffi::GhosttyTerminalScrollViewport { tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_BOTTOM, @@ -1672,11 +1730,11 @@ impl Terminal { self.get_optional_rgb_color(TERMINAL_DATA_COLOR_CURSOR) } - pub(crate) fn width_px(&self) -> Result { + pub fn width_px(&self) -> Result { self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX) } - pub(crate) fn height_px(&self) -> Result { + pub fn height_px(&self) -> Result { self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_HEIGHT_PX) } @@ -1761,11 +1819,42 @@ impl Terminal { ) } - pub(crate) fn kitty_graphics_may_have_placements(&self) -> Result { + pub fn kitty_graphics_may_have_placements(&self) -> Result { let generation = self.kitty_graphics_generation()?; Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation)) } + /// Current fingerprint of each stored image, placed or not, when it is known + /// without hashing pixels. `None` means deleted or replaced since last seen. + pub fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Result>, Error> { + let graphics = self.kitty_graphics()?; + if graphics.is_null() { + return Ok(vec![None; image_ids.len()]); + } + image_ids + .iter() + .map(|&image_id| { + let image = unsafe { ffi::ghostty_kitty_graphics_image(graphics, image_id) }; + if image.is_null() { + return Ok(None); + } + if let Some(source) = native_source::image_source(image)? { + return Ok(Some(source.fingerprint())); + } + let generation = kitty_image_u64( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION, + )?; + Ok(self.kitty_fingerprints.lock().ok().and_then(|cache| { + cache + .get(&image_id) + .filter(|entry| entry.generation == generation) + .map(|entry| entry.fingerprint) + })) + }) + .collect() + } + pub fn kitty_image_placements(&self) -> Result, Error> { self.kitty_image_placements_with_data_filter(|_| true) } @@ -1814,13 +1903,13 @@ impl Terminal { } if !storage_has_placements { self.kitty_empty_generation.set(Some(generation)); - self.prune_kitty_fingerprints(&[]); + self.prune_kitty_fingerprints(graphics); return Ok(Vec::new()); } placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?); placements.sort_by_key(|placement| placement.z); - self.prune_kitty_fingerprints(&placements); + self.prune_kitty_fingerprints(graphics); Ok(placements) } @@ -1865,19 +1954,75 @@ impl Terminal { fingerprint } - fn prune_kitty_fingerprints(&self, placements: &[KittyImagePlacement]) { + /// Keeps fingerprints for hidden images so scrolling them back into view + /// does not re-hash their pixels; generation checks catch replacements. + fn prune_kitty_fingerprints(&self, graphics: ffi::GhosttyKittyGraphics) { if let Ok(mut cache) = self.kitty_fingerprints.lock() { - if cache.is_empty() { - return; - } - let live: HashSet = placements - .iter() - .map(|placement| placement.image_id) - .collect(); - cache.retain(|image_id, _| live.contains(image_id)); + cache.retain(|image_id, _| { + !unsafe { ffi::ghostty_kitty_graphics_image(graphics, *image_id) }.is_null() + }); } } + // Retained files are authoritative: never ask the native lazy decoder for + // pixels just to describe a placement or calculate its cache identity. + fn kitty_placement_payload( + &self, + image: ffi::GhosttyKittyGraphicsImage, + image_id: u32, + placement_id: u32, + image_width: u32, + image_height: u32, + decoded_format: KittyImageFormat, + needs_data: &mut F, + ) -> Result + where + F: FnMut(KittyImageDescriptor) -> bool, + { + let source_file = native_source::image_source(image)?; + let (format, data_ptr, data_len, data_fingerprint) = if let Some(source) = &source_file { + ( + KittyImageFormat::Rgba, + ptr::null(), + source.len(), + source.fingerprint(), + ) + } else { + let (format, data_ptr, data_len) = + kitty_image_transmission_payload(image, decoded_format, self.kitty_png_forwarding)?; + let fingerprint = self.kitty_image_fingerprint_cached( + image, + image_id, + (data_ptr, data_len), + image_width, + image_height, + format, + ); + (format, data_ptr, data_len, fingerprint) + }; + let descriptor = KittyImageDescriptor { + image_id, + placement_id, + image_width, + image_height, + format, + data_len, + data_fingerprint, + source_file: source_file.is_some(), + }; + let data = if needs_data(descriptor) { + match &source_file { + Some(source) => source + .copy_rgba() + .map_err(|_| Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE))?, + None => kitty_image_data_from_ptr(data_ptr, data_len), + } + } else { + Vec::new() + }; + Ok((descriptor, data, source_file)) + } + fn kitty_image_placement( &self, graphics: ffi::GhosttyKittyGraphics, @@ -1933,29 +2078,21 @@ impl Terminal { iterator, ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, )?; - let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?; - let data_fingerprint = self.kitty_image_fingerprint_cached( + let (descriptor, data, source_file) = self.kitty_placement_payload( image, - image_id, - (data_ptr, data_len), - image_width, - image_height, - format, - ); - let descriptor = KittyImageDescriptor { image_id, placement_id, image_width, image_height, + format, + needs_data, + )?; + let KittyImageDescriptor { format, data_len, data_fingerprint, - }; - let data = if needs_data(descriptor) { - kitty_image_data_from_ptr(data_ptr, data_len) - } else { - Vec::new() - }; + .. + } = descriptor; let x_offset = kitty_placement_u32( iterator, ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, @@ -1981,6 +2118,7 @@ impl Terminal { data_len, data_fingerprint, data, + source_file, render: KittyPlacementRenderInfo { pixel_width: raw_info.pixel_width, pixel_height: raw_info.pixel_height, @@ -2077,29 +2215,21 @@ impl Terminal { continue; }; let placement_id = run.synthetic_placement_id(); - let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?; - let data_fingerprint = self.kitty_image_fingerprint_cached( + let (descriptor, data, source_file) = self.kitty_placement_payload( image, - image_id, - (data_ptr, data_len), - image_width, - image_height, - format, - ); - let descriptor = KittyImageDescriptor { image_id, placement_id, image_width, image_height, + format, + needs_data, + )?; + let KittyImageDescriptor { format, data_len, data_fingerprint, - }; - let data = if needs_data(descriptor) { - kitty_image_data_from_ptr(data_ptr, data_len) - } else { - Vec::new() - }; + .. + } = descriptor; placements.push(KittyImagePlacement { image_id, placement_id, @@ -2112,6 +2242,7 @@ impl Terminal { data_len, data_fingerprint, data, + source_file, render: geometry.render, }); } @@ -2371,7 +2502,7 @@ fn kitty_placeholder_diacritic_index(codepoint: u32) -> Option { // Reuse Ghostty's vendored table so Herdr decodes the same placeholder // row/column diacritics that libghostty accepts. let source = - include_str!("../../vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig"); + include_str!("../../../vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig"); let mut map = HashMap::new(); let mut in_table = false; for line in source.lines() { @@ -2581,6 +2712,40 @@ fn kitty_image_compression( Ok(out) } +fn kitty_image_transmission_payload( + image: ffi::GhosttyKittyGraphicsImage, + decoded_format: KittyImageFormat, + forwarding: bool, +) -> Result<(KittyImageFormat, *const u8, usize), Error> { + if !forwarding { + let (ptr, len) = kitty_image_data_ptr_len(image)?; + return Ok((decoded_format, ptr, len)); + } + let mut ptr_out: *const u8 = ptr::null(); + let result = unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR, + (&mut ptr_out as *mut *const u8).cast(), + ) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + let (ptr, len) = kitty_image_data_ptr_len(image)?; + return Ok((decoded_format, ptr, len)); + } + result.into_result()?; + let mut len = 0usize; + unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN, + (&mut len as *mut usize).cast(), + ) + .into_result()?; + } + Ok((KittyImageFormat::Png, ptr_out, len)) +} + fn kitty_image_data_ptr_len( image: ffi::GhosttyKittyGraphicsImage, ) -> Result<(*const u8, usize), Error> { @@ -3036,6 +3201,8 @@ pub struct RowIter<'a> { } impl<'a> RowIter<'a> { + // Advances a native cursor; rows are read in place, so this is not an Iterator. + #[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> bool { // SAFETY: iterator handle is valid while self is alive. unsafe { ffi::ghostty_render_state_row_iterator_next(self.iterator.raw) } @@ -3204,6 +3371,7 @@ impl Default for CellBasicData { } impl<'a> RowCellIter<'a> { + #[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> bool { // SAFETY: cells handle is valid while self is alive. unsafe { ffi::ghostty_render_state_row_cells_next(self.cells.raw) } @@ -3213,6 +3381,17 @@ impl<'a> RowCellIter<'a> { unsafe { ffi::ghostty_render_state_row_cells_select(self.cells.raw, x).into_result() } } + /// Whether the current cell was never written: no text, default style, + /// narrow, and no hyperlink. One read replaces the style and text lookups + /// for the blank cells that fill most wide panes. + pub fn is_default_blank(&self) -> Result { + // Packed cell bits 0..44 hold content, style id, and width; bit 45 is + // the hyperlink flag. Protection (44) and OSC 133 semantic marks + // (46..48) do not change how a cell renders. + const PRESENTATION_BITS: u64 = ((1 << 44) - 1) | (1 << 45); + Ok(self.raw_cell()? & PRESENTATION_BITS == 0) + } + fn raw_cell(&self) -> Result { let mut raw = ffi::GhosttyCell::default(); unsafe { @@ -3588,6 +3767,66 @@ mod tests { assert!(newest.contains("10000")); } + #[test] + fn kitty_png_replacement_rejects_invalid_payload_without_placing_it() { + use base64::Engine as _; + let mut png = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&[255, 0, 0, 255, 0, 0, 255, 255]) + .unwrap(); + } + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + let upload = |payload: &[u8]| { + format!( + "\x1b[H\x1b_Ga=T,f=100,i=7,p=3,c=2,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(payload) + ) + }; + terminal.write(upload(&png).as_bytes()); + let before = terminal.kitty_image_placements().unwrap(); + assert_eq!(before.len(), 1); + assert_eq!((before[0].image_width, before[0].image_height), (2, 1)); + assert_eq!(before[0].data, [255, 0, 0, 255, 0, 0, 255, 255]); + // The parser retires the old placement, but never places corrupt data. + terminal.write(upload(&png[..png.len() / 2]).as_bytes()); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + } + + #[cfg(unix)] + #[test] + fn kitty_file_image_survives_source_mutation_and_unlink() { + use base64::Engine as _; + let path = std::path::PathBuf::from(format!( + "/var/tmp/herdr-kitty-source-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, [255, 0, 0, 255]).unwrap(); + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.write( + format!( + "\x1b_Ga=T,f=32,t=f,i=7,p=3,s=1,v=1,c=1,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(path.to_str().unwrap()) + ) + .as_bytes(), + ); + std::fs::write(&path, [0, 0, 255, 255]).unwrap(); + std::fs::remove_file(&path).unwrap(); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].data, [255, 0, 0, 255]); + } + #[test] fn kitty_image_fingerprint_covers_full_payload() { let mut data = vec![1u8; 4096 * 4]; @@ -3650,6 +3889,33 @@ mod tests { ); } + #[test] + fn kitty_image_fingerprint_survives_hidden_placements_until_the_image_is_deleted() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + let cached = + |terminal: &Terminal| terminal.kitty_fingerprints.lock().unwrap().contains_key(&7); + terminal.write(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=10,r=5,q=2;/wAA/w==\x1b\\"); + assert_eq!( + terminal + .kitty_image_placements_with_data_filter(|_| false) + .unwrap() + .len(), + 1 + ); + assert!(cached(&terminal)); + + terminal.write(b"\x1b_Ga=d,d=a,q=2\x1b\\"); + assert!(terminal + .kitty_image_placements_with_data_filter(|_| false) + .unwrap() + .is_empty()); + assert!(cached(&terminal), "a hidden image must not be re-hashed"); + + terminal.write(b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\"); + let _ = terminal.kitty_image_placements_with_data_filter(|_| false); + assert!(!cached(&terminal)); + } + #[test] fn kitty_storage_generation_skips_only_proven_empty_storage() { let mut terminal = Terminal::new(10, 5, 1_000_000).unwrap(); @@ -3806,6 +4072,8 @@ mod tests { #[cfg(unix)] #[test] fn kitty_graphics_file_upload_can_be_placed_later() { + use base64::Engine as _; + let dir = std::env::temp_dir().join(format!( "herdr-kitty-file-upload-test-{}", std::process::id() @@ -3817,14 +4085,10 @@ mod tests { let mut terminal = Terminal::new(10, 5, 0).unwrap(); terminal.enable_kitty_graphics().unwrap(); terminal.resize(10, 5, 8, 16).unwrap(); - let mut upload = Vec::new(); - crate::kitty_graphics::encode_kitty_regular_file( - &mut upload, - &[], - "a=t,f=32,s=1,v=1,i=10,q=0", - path.to_str().unwrap(), - ); - terminal.write(&upload); + let payload = + base64::engine::general_purpose::STANDARD.encode(path.to_str().unwrap().as_bytes()); + let upload = format!("\x1b7\x1b_Ga=t,f=32,s=1,v=1,i=10,q=0,t=f;{payload}\x1b\\\x1b8"); + terminal.write(upload.as_bytes()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); terminal.write(b"\x1b_Ga=p,i=10,p=5,c=10,r=5,C=1,q=2\x1b\\"); diff --git a/crates/ghostty-vt/src/native_image_sources.rs b/crates/ghostty-vt/src/native_image_sources.rs new file mode 100644 index 00000000..b4d3b073 --- /dev/null +++ b/crates/ghostty-vt/src/native_image_sources.rs @@ -0,0 +1,96 @@ +//! Bounded kernel CoW snapshots. No read/write or whole-file clone fallback. +use std::fs::File; +use std::io; +use std::os::fd::AsRawFd; + +#[repr(C)] +struct CloneRange { + src_fd: i64, + src_offset: u64, + src_length: u64, + dest_offset: u64, +} + +fn stat(fd: libc::c_int) -> io::Result { + let mut result = std::mem::MaybeUninit::::uninit(); + // SAFETY: fstat initializes the supplied stat on success; fd remains borrowed. + if unsafe { libc::fstat(fd, result.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: successful fstat initialized the value. + Ok(unsafe { result.assume_init() }) +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid native snapshot source or destination", + ) +} + +pub(crate) fn clone_native_image_source( + source_fd: i64, + destination: &File, + expected_len: usize, +) -> io::Result<()> { + let fd = libc::c_int::try_from(source_fd).map_err(|_| invalid())?; + if fd < 0 + || expected_len == 0 + || expected_len > 16 * 1024 * 1024 + || !expected_len.is_multiple_of(4) + { + return Err(invalid()); + } + let source = stat(fd)?; + let dest = stat(destination.as_raw_fd())?; + // SAFETY: geteuid has no preconditions. + let uid = unsafe { libc::geteuid() }; + let valid_source = |value: &libc::stat| { + value.st_mode & libc::S_IFMT == libc::S_IFREG + && value.st_uid == uid + && value.st_size == expected_len as i64 + && value.st_dev == source.st_dev + && value.st_ino == source.st_ino + }; + if !valid_source(&source) + || dest.st_mode & libc::S_IFMT != libc::S_IFREG + || dest.st_mode & 0o777 != 0o600 + || dest.st_uid != uid + || dest.st_nlink != 1 + || dest.st_size != 0 + || (dest.st_dev == source.st_dev && dest.st_ino == source.st_ino) + { + return Err(invalid()); + } + let range = CloneRange { + src_fd: source_fd, + src_offset: 0, + src_length: expected_len as u64, + dest_offset: 0, + }; + // Explicit nonzero length is essential: FICLONE (or a zero range length) + // clones through EOF and source growth could exceed the reservation before + // post-validation. This request cannot extend the destination past the + // reservation even if the producer changes size concurrently. Filesystems + // requiring block alignment may reject an EOF range after concurrent growth; + // that is a normal fallback, never a reason to try an unbounded clone. + // SAFETY: the request consumes a valid repr(C) range, both descriptors stay + // open throughout the call, and the source descriptor is never owned here. + if unsafe { libc::ioctl(destination.as_raw_fd(), libc::FICLONERANGE, &range) } != 0 { + return Err(io::Error::last_os_error()); + } + let after = stat(destination.as_raw_fd())?; + if !valid_source(&stat(fd)?) + || after.st_dev != dest.st_dev + || after.st_ino != dest.st_ino + || after.st_size != expected_len as i64 + || after.st_nlink != 1 + || after.st_uid != uid + || after.st_mode & 0o777 != 0o600 + { + return Err(invalid()); + } + // The kernel serializes clone against writes. These stats establish exact + // length at validation points, not producer quiescence or a content hash. + Ok(()) +} diff --git a/crates/ghostty-vt/src/native_source.rs b/crates/ghostty-vt/src/native_source.rs new file mode 100644 index 00000000..3e4e8a3f --- /dev/null +++ b/crates/ghostty-vt/src/native_source.rs @@ -0,0 +1,165 @@ +//! Host-owned immutable file snapshots retained by the native image lifetime. +use super::{ffi, Error, GhosttyResultExt}; +use crate::pane_graphics_files::{FileStore, OwnedExport}; +use std::ffi::c_void; +use std::ptr; +use std::sync::{Arc, OnceLock}; + +static SOURCES: OnceLock = OnceLock::new(); + +// These getters never materialize native pixels. The image borrow protects the +// host's strong reference until we have acquired our own. +pub(super) fn image_source( + image: ffi::GhosttyKittyGraphicsImage, +) -> Result>, Error> { + let mut context: *mut c_void = ptr::null_mut(); + let result = unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT, + (&mut context as *mut *mut c_void).cast(), + ) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + return Ok(None); + } + result.into_result()?; + if context.is_null() { + return Ok(None); + } + let mut identity = 0u64; + unsafe { + ffi::ghostty_kitty_graphics_image_get( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY, + (&mut identity as *mut u64).cast(), + ) + .into_result()?; + let source = context.cast::(); + // All terminal snapshot callbacks in this wrapper produce this type. + // Identity is immutable and does not depend on decoded pixel storage. + if (*source).fingerprint() != identity { + return Err(Error(ffi::GhosttyResult_GHOSTTY_INVALID_VALUE)); + } + Arc::increment_strong_count(source); + Ok(Some(Arc::from_raw(source))) + } +} + +pub(super) fn set_forwarding(terminal: ffi::GhosttyTerminal, enabled: bool) -> Result<(), Error> { + let callback = if enabled && cfg!(target_os = "linux") { + snapshot as *const () as *const c_void + } else { + ptr::null() + }; + unsafe { + ffi::ghostty_terminal_set( + terminal, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE, + callback, + ) + .into_result() + } +} + +unsafe extern "C" fn snapshot( + _terminal: ffi::GhosttyTerminal, + _userdata: *mut c_void, + request: *const ffi::GhosttyKittyImageSnapshotFileRequest, + out: *mut ffi::GhosttyKittyImageFileBacking, +) -> bool { + std::panic::catch_unwind(|| { + if request.is_null() || out.is_null() || !cfg!(target_os = "linux") { + return false; + } + let request = unsafe { &*request }; + if request.size != std::mem::size_of::() { + return false; + } + let Ok(len) = usize::try_from(request.expected_len) else { + return false; + }; + let Ok(source) = SOURCES + .get_or_init(FileStore::native_sources) + .snapshot(request.fd, len) + else { + return false; + }; + let source = Arc::new(source); + let backing = ffi::GhosttyKittyImageFileBacking { + identity: source.fingerprint(), + len: source.len(), + context: Arc::into_raw(source).cast_mut().cast(), + read: Some(read), + release: Some(release), + }; + unsafe { + out.write(backing); + } + true + }) + .unwrap_or(false) +} + +unsafe extern "C" fn read(context: *mut c_void, dest: *mut u8, len: usize) -> bool { + std::panic::catch_unwind(|| { + if context.is_null() || dest.is_null() { + return false; + } + let source = unsafe { &*context.cast::() }; + if len != source.len() || len > isize::MAX as usize { + return false; + } + source + .read_into(unsafe { std::slice::from_raw_parts_mut(dest, len) }) + .is_ok() + }) + .unwrap_or(false) +} + +unsafe extern "C" fn release(context: *mut c_void) { + let _ = std::panic::catch_unwind(|| { + if !context.is_null() { + drop(unsafe { Arc::from_raw(context.cast::()) }); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_requests_decline_without_transferring_ownership() { + let mut out = ffi::GhosttyKittyImageFileBacking::default(); + let mut request = ffi::GhosttyKittyImageSnapshotFileRequest { + size: std::mem::size_of::(), + fd: -1, + expected_len: 4, + }; + unsafe { + assert!(!snapshot( + ptr::null_mut(), + ptr::null_mut(), + &request, + &mut out + )); + assert!(out.context.is_null()); + request.size = 0; + assert!(!snapshot( + ptr::null_mut(), + ptr::null_mut(), + &request, + &mut out + )); + assert!(!snapshot( + ptr::null_mut(), + ptr::null_mut(), + ptr::null(), + &mut out + )); + assert!(out.context.is_null()); + release(ptr::null_mut()); + } + } +} diff --git a/crates/ghostty-vt/src/native_source_tests.rs b/crates/ghostty-vt/src/native_source_tests.rs new file mode 100644 index 00000000..bb4ded46 --- /dev/null +++ b/crates/ghostty-vt/src/native_source_tests.rs @@ -0,0 +1,146 @@ +use super::*; +use base64::Engine as _; + +fn terminal() -> Terminal { + let mut terminal = Terminal::new(20, 10, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.resize(20, 10, 8, 16).unwrap(); + terminal +} + +#[test] +fn decoded_upload_remains_bytes_backed() { + let mut terminal = terminal(); + let bytes = [17u8; 16]; + terminal.write( + format!( + "\x1b_Ga=T,f=32,s=2,v=2,i=7,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + .as_bytes(), + ); + let metadata = terminal + .kitty_image_placements_with_data_filter(|descriptor| { + assert!(!descriptor.source_file); + assert_eq!(descriptor.data_len, bytes.len()); + false + }) + .unwrap(); + assert_eq!(metadata.len(), 1); + assert!(metadata[0].data.is_empty()); + assert!(metadata[0].source_file.is_none()); + let compatible = terminal.kitty_image_placements().unwrap(); + assert_eq!(compatible[0].data, bytes); + assert_eq!(compatible[0].data_fingerprint, metadata[0].data_fingerprint); +} + +#[cfg(unix)] +#[test] +fn regular_file_placement_survives_alternate_screen_replacement() { + let store = crate::pane_graphics_files::FileStore::default(); + let mut terminal = terminal(); + terminal.set_kitty_source_forwarding(false).unwrap(); + terminal.write(b"\x1b[?1049h\x1b[2J\x1b[H"); + for value in [17u8, 99] { + let bytes = [value; 16]; + let source = store.export(&bytes).unwrap(); + terminal.write( + format!( + "\x1b[H\x1b_Ga=T,t=f,f=32,s=2,v=2,i=77,p=1,c=2,r=1,C=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD + .encode(source.path().as_os_str().as_encoded_bytes()) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].data, bytes); + assert_eq!(placements[0].render.viewport_row, 0); + assert_eq!(placements[0].render.grid_rows, 1); + assert_eq!(placements[0].render.grid_cols, 2); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn file_upload_automatically_retains_snapshot_when_platform_supports_clone() { + use std::os::fd::AsRawFd; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let base = std::env::var_os("TMPDIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| "/var/tmp".into()); + let path = base.join(format!( + "herdr-native-source-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let bytes = [42u8; 16]; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + use std::io::Write; + (&file).write_all(&bytes).unwrap(); + // Probe the actual platform, not filesystem names: overlayfs/tmpfs must + // decline rather than silently produce a userspace copy. + let probe = crate::pane_graphics_files::FileStore::native_sources() + .snapshot(i64::from(file.as_raw_fd()), bytes.len()); + let supported = match probe { + Ok(snapshot) => { + drop(snapshot); + true + } + Err(error) => { + eprintln!("native snapshot unsupported on test filesystem: {error}"); + false + } + }; + let mut terminal = terminal(); + terminal.write( + format!( + "\x1b_Ga=T,t=f,f=32,s=2,v=2,i=7,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_encoded_bytes()) + ) + .as_bytes(), + ); + let metadata = terminal + .kitty_image_placements_with_data_filter(|descriptor| { + assert_eq!(descriptor.source_file, supported); + false + }) + .unwrap(); + assert_eq!(metadata.len(), 1); + assert!(metadata[0].data.is_empty()); + assert_eq!(metadata[0].source_file.is_some(), supported); + let mut unicode = self::terminal(); + unicode.write( + format!( + "\x1b_Ga=T,t=f,f=32,s=2,v=2,U=1,c=1,r=1,i=8,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(path.as_os_str().as_encoded_bytes()) + ) + .as_bytes(), + ); + unicode.write("\x1b[H\x1b[38;2;0;0;8m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes()); + let unicode_metadata = unicode + .kitty_image_placements_with_data_filter(|descriptor| { + assert_eq!(descriptor.source_file, supported); + false + }) + .unwrap(); + assert_eq!(unicode_metadata.len(), 1); + assert!(unicode_metadata[0].data.is_empty()); + assert_eq!(unicode_metadata[0].source_file.is_some(), supported); + std::fs::write(&path, [99u8; 16]).unwrap(); + assert_eq!(unicode.kitty_image_placements().unwrap()[0].data, bytes); + let compatible = terminal.kitty_image_placements().unwrap(); + assert_eq!(compatible[0].data, bytes); + assert_eq!(compatible[0].data_fingerprint, metadata[0].data_fingerprint); + drop(terminal); + if let Some(source) = &metadata[0].source_file { + assert_eq!(source.copy_rgba().unwrap(), bytes); + } + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/ghostty-vt/src/pane_graphics_files.rs b/crates/ghostty-vt/src/pane_graphics_files.rs new file mode 100644 index 00000000..c5f844d2 --- /dev/null +++ b/crates/ghostty-vt/src/pane_graphics_files.rs @@ -0,0 +1,1041 @@ +#[cfg(unix)] +use std::fs::OpenOptions; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}; + +#[cfg(target_os = "linux")] +use crate::native_image_sources::clone_native_image_source; + +/// Kernel CoW snapshots are deliberately unsupported outside Linux. +#[cfg(not(target_os = "linux"))] +fn clone_native_image_source( + _source_fd: i64, + _destination: &File, + _expected_len: usize, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "native source cloning requires Linux", + )) +} + +// Herdr's default log filter is `herdr=info`; keep these warnings under its target. +const LOG_TARGET: &str = "herdr::pane_graphics_files"; + +#[cfg(unix)] +const DIRECTORY_MODE: u32 = 0o700; +#[cfg(unix)] +const FILE_MODE: u32 = 0o600; + +#[derive(Debug)] +pub struct FileStore { + base: PathBuf, + generation: OnceLock>, + next_fingerprint: AtomicU64, + native_budget: Option>>, +} + +#[derive(Debug)] +struct Generation { + root: PathBuf, + source: PathBuf, +} + +/// Keep this owned snapshot alive until the terminal has consumed its path. +#[derive(Debug)] +pub struct OwnedExport { + lease: Lease, + _file: ExportFile, + _reservation: Option, +} + +// Also owns cleanup during writing/validation, before an export is published. +#[derive(Debug)] +struct ExportFile { + path: PathBuf, + _generation: Arc, +} + +#[derive(Debug, Default)] +struct NativeBudget { + bytes: usize, + objects: usize, +} + +#[derive(Debug)] +struct Reservation { + budget: Arc>, + len: usize, +} + +impl Reservation { + fn acquire(budget: &Arc>, len: usize) -> io::Result { + let mut state = budget.lock().unwrap_or_else(|err| err.into_inner()); + if state.objects >= 64 || len > 64 * 1024 * 1024 - state.bytes { + return Err(io::Error::other("native snapshot budget exhausted")); + } + state.objects += 1; + state.bytes += len; + Ok(Self { + budget: Arc::clone(budget), + len, + }) + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + let mut state = self.budget.lock().unwrap_or_else(|err| err.into_inner()); + state.objects -= 1; + state.bytes -= self.len; + } +} + +const MAX_EXPORT_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Debug)] +struct Lease { + path: PathBuf, + file: File, + generation: Arc, + metadata: fs::Metadata, + len: usize, + fingerprint: u64, +} + +impl Default for FileStore { + fn default() -> Self { + Self::new(runtime_base()) + } +} + +impl FileStore { + fn new(base: PathBuf) -> Self { + Self { + base, + generation: OnceLock::new(), + next_fingerprint: AtomicU64::new(1), + native_budget: None, + } + } + + pub fn native_sources() -> Self { + let mut store = Self::new(native_base()); + store.native_budget = Some(Arc::new(Mutex::new(NativeBudget::default()))); + store + } + + /// Kernel-only bounded snapshot; any unsupported clone returns an error. + /// The caller keeps the borrowed descriptor alive throughout this call. + pub fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result { + if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid native RGBA length", + )); + } + let budget = self.native_budget.as_ref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "not a native source store") + })?; + let reservation = Reservation::acquire(budget, expected_len)?; + let generation = self.generation()?; + let id = self.next_fingerprint.fetch_add(1, Ordering::Relaxed); + let path = generation.source.join(format!("native-{id}.rgba")); + let destination = create_export(&path)?; + let file = ExportFile { + path, + _generation: generation, + }; + clone_native_image_source(source_fd, &destination, expected_len)?; + drop(destination); + let lease = self.lease(&file.path, expected_len)?; + Ok(OwnedExport { + lease, + _file: file, + _reservation: Some(reservation), + }) + } + + // Not test-gated: herdr's own tests use it, and a dependency's cfg(test) is off. + #[cfg(unix)] + pub fn source_directory(&self) -> io::Result { + Ok(self.generation()?.source.clone()) + } + + fn lease(&self, path: &Path, expected_len: usize) -> io::Result { + let generation = self.generation()?; + validate_child(path, &generation.source)?; + let file = open_no_follow(path)?; + let metadata = file.metadata()?; + validate_metadata(&metadata, expected_len)?; + validate_path_identity(path, &metadata)?; + let fingerprint = self.next_fingerprint.fetch_add(1, Ordering::Relaxed); + Ok(Lease { + path: path.to_owned(), + file, + generation, + metadata, + len: expected_len, + fingerprint, + }) + } + + /// Snapshot decoded bytes into a private, uniquely named file. Aggregate + /// outstanding-export limits are the caller's responsibility. + pub fn export(&self, data: &[u8]) -> io::Result { + if data.len() > MAX_EXPORT_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "decoded export exceeds 16 MiB", + )); + } + let reservation = self + .native_budget + .as_ref() + .map(|budget| Reservation::acquire(budget, data.len())) + .transpose()?; + let generation = self.generation()?; + // Share the lease identity allocator; create_new also protects against + // collisions with producer files. Never overwrite or remove a collision. + let id = self.next_fingerprint.fetch_add(1, Ordering::Relaxed); + let path = generation.source.join(format!("decoded-{id}.rgba")); + let mut writer = create_export(&path)?; + let file = ExportFile { + path, + _generation: generation, + }; + writer.write_all(data)?; + drop(writer); + let lease = self.lease(&file.path, data.len())?; + Ok(OwnedExport { + lease, + _file: file, + _reservation: reservation, + }) + } + + fn generation(&self) -> io::Result> { + if let Some(generation) = self.generation.get() { + return Ok(Arc::clone(generation)); + } + let generation = Arc::new(create_generation(&self.base)?); + match self.generation.set(Arc::clone(&generation)) { + Ok(()) => Ok(generation), + Err(_) => self + .generation + .get() + .cloned() + .ok_or_else(|| io::Error::other("pane graphics generation was lost")), + } + } + + #[cfg(all(test, unix))] + pub(crate) fn is_initialized(&self) -> bool { + self.generation.get().is_some() + } +} + +impl PartialEq for OwnedExport { + fn eq(&self, other: &Self) -> bool { + self.path() == other.path() && self.fingerprint() == other.fingerprint() + } +} + +impl Eq for OwnedExport {} + +impl OwnedExport { + pub fn copy_rgba(&self) -> io::Result> { + self.lease.copy_rgba() + } + + pub(crate) fn read_into(&self, data: &mut [u8]) -> io::Result<()> { + self.lease.read_into(data) + } + + pub fn path(&self) -> &Path { + self.lease.path() + } + + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.lease.len() + } + + pub fn fingerprint(&self) -> u64 { + self.lease.fingerprint() + } +} + +impl Drop for ExportFile { + fn drop(&mut self) { + if let Err(err) = fs::remove_file(&self.path) { + if err.kind() != io::ErrorKind::NotFound { + tracing::warn!(target: LOG_TARGET, path = %self.path.display(), err = %err, "failed to remove decoded graphics export"); + } + } + } +} + +impl Lease { + fn path(&self) -> &Path { + &self.path + } + + fn len(&self) -> usize { + self.len + } + + fn fingerprint(&self) -> u64 { + self.fingerprint + } + + fn copy_rgba(&self) -> io::Result> { + let mut data = vec![0; self.len]; + self.read_into(&mut data)?; + Ok(data) + } + + fn read_into(&self, data: &mut [u8]) -> io::Result<()> { + if data.len() != self.len { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "incorrect output length", + )); + } + let _keep_generation_alive = &self.generation; + validate_path_identity(&self.path, &self.metadata)?; + read_exact_at(&self.file, data)?; + if has_byte_at(&self.file, self.len as u64)? { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "frame length changed while leased", + )); + } + validate_metadata(&self.file.metadata()?, self.len)?; + validate_path_identity(&self.path, &self.metadata)?; + Ok(()) + } +} + +impl Drop for Generation { + fn drop(&mut self) { + if let Err(err) = fs::remove_dir_all(&self.root) { + if err.kind() != io::ErrorKind::NotFound { + tracing::warn!(target: LOG_TARGET, path = %self.root.display(), err = %err, "failed to remove pane graphics directory"); + } + } + } +} + +#[cfg(unix)] +pub fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> { + validate_source_under(path, expected_len, runtime_base()) +} + +#[cfg(unix)] +fn validate_source_under(path: &Path, expected_len: usize, base: PathBuf) -> io::Result<()> { + let source = path.parent().ok_or_else(invalid_path)?; + let generation = source.parent().ok_or_else(invalid_path)?; + if !path.is_absolute() + || path.file_name().is_none() + || source.file_name().and_then(|name| name.to_str()) != Some("source") + || generation.parent() != Some(base.as_path()) + || !generation + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("server-")) + { + return Err(invalid_path()); + } + for directory in [base, generation.to_owned(), source.to_owned()] { + validate_directory(&directory)?; + } + let file = open_no_follow(path)?; + let metadata = file.metadata()?; + validate_metadata(&metadata, expected_len)?; + validate_path_identity(path, &metadata) +} + +/// Unlike the API validator, accepts only the dedicated native hierarchy. +#[cfg(unix)] +pub fn validate_native_source(path: &Path, expected_len: usize) -> io::Result<()> { + if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) { + return Err(invalid_path()); + } + validate_source_under(path, expected_len, native_base()) +} + +fn native_base() -> PathBuf { + #[cfg(unix)] + { + PathBuf::from(format!("/var/tmp/herdr-native-sources-{}", effective_uid())) + } + #[cfg(not(unix))] + { + PathBuf::from("native-sources-unavailable") + } +} + +fn create_generation(base: &Path) -> io::Result { + #[cfg(not(unix))] + { + let _ = base; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "file-backed pane graphics require Unix", + )) + } + #[cfg(unix)] + { + if base == native_base() { + match fs::DirBuilder::new().mode(DIRECTORY_MODE).create(base) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err), + } + } else { + fs::create_dir_all(base)?; + fs::set_permissions(base, fs::Permissions::from_mode(DIRECTORY_MODE))?; + } + validate_directory(base)?; + remove_stale_generations(base); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let root = base.join(format!("server-{}-{nonce}", std::process::id())); + fs::create_dir(&root)?; + fs::set_permissions(&root, fs::Permissions::from_mode(DIRECTORY_MODE))?; + let source = root.join("source"); + fs::create_dir(&source)?; + fs::set_permissions(&source, fs::Permissions::from_mode(DIRECTORY_MODE))?; + validate_directory(&root)?; + validate_directory(&source)?; + Ok(Generation { root, source }) + } +} + +fn runtime_base() -> PathBuf { + #[cfg(unix)] + { + let root = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| PathBuf::from("/var/tmp")); + root.join(format!("herdr-pane-graphics-{}", effective_uid())) + } + #[cfg(not(unix))] + { + PathBuf::from("pane-graphics-unavailable") + } +} + +#[cfg(unix)] +fn read_exact_at(file: &File, data: &mut [u8]) -> io::Result<()> { + file.read_exact_at(data, 0) +} + +#[cfg(not(unix))] +fn read_exact_at(_file: &File, _data: &mut [u8]) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) +} + +#[cfg(unix)] +fn has_byte_at(file: &File, offset: u64) -> io::Result { + Ok(file.read_at(&mut [0], offset)? != 0) +} + +#[cfg(not(unix))] +fn has_byte_at(_file: &File, _offset: u64) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) +} + +#[cfg(unix)] +fn remove_stale_generations(base: &Path) { + let Ok(entries) = fs::read_dir(base) else { + return; + }; + for entry in entries.flatten() { + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|name| name.strip_prefix("server-")) + .and_then(|name| name.split('-').next()) + .and_then(|pid| pid.parse::().ok()) + .filter(|pid| *pid > 0) + else { + continue; + }; + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + // SAFETY: kill with signal 0 probes process existence without sending a signal. + let alive = unsafe { libc::kill(pid, 0) } == 0; + if alive || io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) { + continue; + } + if let Err(err) = fs::remove_dir_all(entry.path()) { + tracing::warn!(target: LOG_TARGET, path = %entry.path().display(), err = %err, "failed to remove stale pane graphics directory"); + } + } +} + +fn validate_child(path: &Path, directory: &Path) -> io::Result<()> { + if !path.is_absolute() || path.parent() != Some(directory) || path.file_name().is_none() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "frame path must be a direct child of the advertised source directory", + )); + } + Ok(()) +} + +fn create_export(path: &Path) -> io::Result { + #[cfg(unix)] + { + OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + } + #[cfg(not(unix))] + { + let _ = path; + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +fn open_no_follow(path: &Path) -> io::Result { + #[cfg(unix)] + { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + } + #[cfg(not(unix))] + { + let _ = path; + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +fn validate_metadata(metadata: &fs::Metadata, expected_len: usize) -> io::Result<()> { + #[cfg(unix)] + { + if !metadata.file_type().is_file() + || metadata.uid() != effective_uid() + || metadata.mode() & 0o777 != FILE_MODE + || metadata.nlink() != 1 + || metadata.len() != expected_len as u64 + { + return Err(invalid( + "frame must be same-uid regular 0600 single-link exact-length file", + )); + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = (metadata, expected_len); + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +#[cfg(unix)] +fn validate_directory(path: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_dir() + || metadata.uid() != effective_uid() + || metadata.mode() & 0o777 != DIRECTORY_MODE + { + return Err(invalid("pane graphics directory is not private")); + } + Ok(()) +} + +fn validate_path_identity(path: &Path, expected: &fs::Metadata) -> io::Result<()> { + #[cfg(unix)] + { + let actual = fs::symlink_metadata(path)?; + if actual.dev() != expected.dev() || actual.ino() != expected.ino() { + return Err(invalid("frame path changed while leased")); + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = (path, expected); + Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only")) + } +} + +#[cfg(unix)] +fn effective_uid() -> u32 { + // SAFETY: geteuid takes no arguments and has no preconditions. + unsafe { libc::geteuid() } +} + +#[cfg(unix)] +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(unix)] +fn invalid_path() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "invalid pane graphics source path", + ) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::io::Write; + use std::os::unix::fs::{symlink, PermissionsExt}; + use std::sync::atomic::AtomicU64; + + static NEXT: AtomicU64 = AtomicU64::new(1); + + fn store() -> (FileStore, PathBuf) { + let base = PathBuf::from(format!( + "/var/tmp/herdr-graphics-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + (FileStore::new(base.clone()), base) + } + + fn frame(store: &FileStore, name: &str, data: &[u8]) -> PathBuf { + let path = store.source_directory().unwrap().join(name); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&path) + .unwrap(); + file.write_all(data).unwrap(); + path + } + + #[test] + fn native_budget_is_per_store_and_released_only_at_final_owner_drop() { + let store = FileStore::native_sources(); + let other = FileStore::native_sources(); + let budget = store.native_budget.as_ref().unwrap(); + let mut holds = Vec::new(); + for _ in 0..64 { + holds.push(Reservation::acquire(budget, 4).unwrap()); + } + assert!(Reservation::acquire(budget, 4).is_err()); + assert!(Reservation::acquire(other.native_budget.as_ref().unwrap(), 4).is_ok()); + holds.clear(); + for _ in 0..4 { + holds.push(Reservation::acquire(budget, MAX_EXPORT_BYTES).unwrap()); + } + assert!(Reservation::acquire(budget, 4).is_err()); + holds.clear(); + // Exercise the exact OwnedExport lifetime independently of FS reflink support. + let export = store.export(&[1, 2, 3, 4]).unwrap(); + let first = Arc::new(export); + let last = Arc::clone(&first); + let path = first.path().to_owned(); + drop(first); + assert_eq!(budget.lock().unwrap().objects, 1); + assert!(path.exists()); + drop(last); + assert_eq!(budget.lock().unwrap().objects, 0); + assert!(!path.exists()); + } + + #[test] + fn native_validation_is_separate_and_private() { + let store = FileStore::native_sources(); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + validate_native_source(export.path(), 4).unwrap(); + assert!(validate_direct_source(export.path(), 4).is_err()); + assert!(validate_native_source(export.path(), 8).is_err()); + let decoded = FileStore::default().export(&[1, 2, 3, 4]).unwrap(); + assert!(validate_native_source(decoded.path(), 4).is_err()); + let link = export.path().with_extension("link"); + symlink(export.path(), &link).unwrap(); + assert!(validate_native_source(&link, 4).is_err()); + fs::remove_file(&link).unwrap(); + fs::hard_link(export.path(), &link).unwrap(); + assert!(validate_native_source(export.path(), 4).is_err()); + fs::remove_file(link).unwrap(); + fs::set_permissions(export.path(), fs::Permissions::from_mode(0o644)).unwrap(); + assert!(validate_native_source(export.path(), 4).is_err()); + } + + #[test] + fn snapshot_validation_releases_budget_and_cleans_staging() { + let store = FileStore::native_sources(); + for len in [0, 3, MAX_EXPORT_BYTES + 4] { + assert!(store.snapshot(-1, len).is_err()); + } + assert!(!store.is_initialized()); + assert!(store.snapshot(-1, 4).is_err()); + assert_eq!( + store + .native_budget + .as_ref() + .unwrap() + .lock() + .unwrap() + .objects, + 0 + ); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 0 + ); + } + + #[test] + fn snapshot_is_independent_and_preserves_borrowed_fd_offset_when_supported() { + use std::io::{Seek, SeekFrom}; + use std::os::fd::AsRawFd; + let store = FileStore::native_sources(); + let path = frame(&store, "producer", &vec![7; 4096]); + let mut producer = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + producer.seek(SeekFrom::Start(13)).unwrap(); + assert!(store.snapshot(producer.as_raw_fd() as i64, 4).is_err()); + let result = store.snapshot(producer.as_raw_fd() as i64, 4096); + assert_eq!(producer.stream_position().unwrap(), 13); + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(err) => { + // Unsupported FS/range/cross-device is a normal parent-reader fallback. + assert!( + err.kind() == io::ErrorKind::Unsupported + || matches!( + err.raw_os_error(), + Some( + libc::EOPNOTSUPP + | libc::ENOTTY + | libc::EXDEV + | libc::EINVAL + | libc::ENOSYS + ) + ), + "{err}" + ); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 1 + ); + assert_eq!( + store + .native_budget + .as_ref() + .unwrap() + .lock() + .unwrap() + .objects, + 0 + ); + return; + } + }; + producer.write_at(&vec![9; 4096], 0).unwrap(); + assert_eq!(snapshot.copy_rgba().unwrap(), vec![7; 4096]); + producer.set_len(0).unwrap(); + fs::remove_file(path).unwrap(); + let mut bytes = vec![0; 4096]; + snapshot.read_into(&mut bytes).unwrap(); + assert_eq!(bytes, vec![7; 4096]); + assert!(snapshot.read_into(&mut [0; 4]).is_err()); + assert_eq!(producer.stream_position().unwrap(), 13); + } + + #[test] + fn tmpfs_source_clone_failure_leaves_no_artifact() { + use std::os::fd::AsRawFd; + let path = PathBuf::from(format!( + "/dev/shm/herdr-snapshot-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let Ok(mut producer) = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&path) + else { + return; + }; + producer.write_all(&[1, 2, 3, 4]).unwrap(); + fs::remove_file(path).unwrap(); + let store = FileStore::native_sources(); + assert!(store.snapshot(producer.as_raw_fd() as i64, 4).is_err()); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 0 + ); + assert_eq!( + store + .native_budget + .as_ref() + .unwrap() + .lock() + .unwrap() + .objects, + 0 + ); + } + + #[test] + fn decoded_exports_snapshot_bytes_and_have_distinct_identities() { + let (store, base) = store(); + let mut data = vec![1, 2, 3, 4]; + let first = store.export(&data).unwrap(); + data.fill(9); + let second = store.export(&data).unwrap(); + assert_eq!(first.len(), 4); + assert_eq!(fs::read(first.path()).unwrap(), [1, 2, 3, 4]); + assert_eq!(first.lease.copy_rgba().unwrap(), [1, 2, 3, 4]); + assert_eq!(second.lease.copy_rgba().unwrap(), data); + assert_ne!(first.path(), second.path()); + assert_ne!(first.fingerprint(), second.fingerprint()); + assert_eq!( + first.path().parent().unwrap(), + store.source_directory().unwrap() + ); + validate_metadata(&fs::metadata(first.path()).unwrap(), 4).unwrap(); + let path = first.path().to_owned(); + drop(first); + assert!(!path.exists()); + assert!(second.path().exists()); + drop(second); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn decoded_export_guard_retains_generation_until_consumed() { + let (store, base) = store(); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + let path = export.path().to_owned(); + let source = store.source_directory().unwrap(); + drop(store); + assert_eq!(fs::read(&path).unwrap(), [1, 2, 3, 4]); + assert!(source.exists()); + drop(export); + assert!(!path.exists()); + assert!(!source.exists()); + let _ = fs::remove_dir(base); + } + + #[test] + fn decoded_export_rejects_oversize_and_never_overwrites_collisions() { + let (store, base) = store(); + assert!(store.export(&vec![0; MAX_EXPORT_BYTES + 1]).is_err()); + assert!(!store.is_initialized()); + let collision = frame(&store, "decoded-1.rgba", &[7, 8]); + assert!(store.export(&[1, 2, 3, 4]).is_err()); + assert_eq!(fs::read(&collision).unwrap(), [7, 8]); + assert_eq!( + fs::read_dir(store.source_directory().unwrap()) + .unwrap() + .count(), + 1 + ); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + assert_ne!(export.path(), collision); + drop(export); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn unpublished_export_guard_removes_partial_file_on_error() { + let (store, base) = store(); + let generation = store.generation().unwrap(); + let path = generation.source.join("partial.rgba"); + let result = (|| -> io::Result<()> { + let mut writer = create_export(&path)?; + let _cleanup = ExportFile { + path: path.clone(), + _generation: generation, + }; + writer.write_all(&[1, 2])?; + drop(writer); + store.lease(&path, 4)?; + Ok(()) + })(); + assert!(result.is_err()); + assert!(!path.exists()); + assert!(store.source_directory().unwrap().exists()); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn decoded_export_passes_direct_source_validation() { + let store = FileStore::default(); + let export = store.export(&[1, 2, 3, 4]).unwrap(); + validate_direct_source(export.path(), export.len()).unwrap(); + } + + #[test] + fn source_directory_is_lazy_private_and_removed_with_store() { + let (store, base) = store(); + assert!(!store.is_initialized()); + let source = store.source_directory().unwrap(); + assert!(store.is_initialized()); + validate_directory(&source).unwrap(); + drop(store); + assert!(!source.exists()); + let _ = fs::remove_dir(base); + } + + #[test] + fn lease_holds_exact_file_and_copies_from_validated_fd() { + let (store, base) = store(); + let path = frame(&store, "frame", &[1, 2, 3, 4]); + let lease = store.lease(&path, 4).unwrap(); + assert_eq!(lease.path(), path); + assert_eq!(lease.len(), 4); + assert!(lease.fingerprint() > 0); + assert_eq!(lease.copy_rgba().unwrap(), [1, 2, 3, 4]); + drop(lease); + drop(store); + let _ = fs::remove_dir(base); + } + + #[test] + fn validation_rejects_links_modes_lengths_and_outside_paths() { + let cases = ["mode", "length", "hard-link", "symlink", "outside"]; + for case in cases { + let (store, base) = store(); + let path = frame(&store, "frame", &[1, 2, 3, 4]); + let result = match case { + "mode" => { + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + store.lease(&path, 4) + } + "length" => store.lease(&path, 8), + "hard-link" => { + fs::hard_link(&path, store.source_directory().unwrap().join("other")).unwrap(); + store.lease(&path, 4) + } + "symlink" => { + let link = store.source_directory().unwrap().join("link"); + symlink(&path, &link).unwrap(); + store.lease(&link, 4) + } + "outside" => store.lease(&base.join("outside"), 4), + _ => unreachable!(), + }; + assert!(result.is_err(), "{case}"); + drop(store); + let _ = fs::remove_dir_all(base); + } + } + + #[test] + fn direct_source_validation_accepts_only_the_runtime_generation() { + let runtime_store = FileStore::default(); + let runtime_path = frame(&runtime_store, "direct", &[1, 2, 3, 4]); + validate_direct_source(&runtime_path, 4).unwrap(); + assert!(validate_direct_source(&runtime_path, 8).is_err()); + assert!(validate_direct_source(Path::new("relative"), 4).is_err()); + + let generation = runtime_path.parent().unwrap().parent().unwrap(); + let wrong_source = generation.join("frames"); + fs::create_dir(&wrong_source).unwrap(); + fs::set_permissions(&wrong_source, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + let wrong_source_path = wrong_source.join("frame"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&wrong_source_path) + .unwrap(); + file.write_all(&[1, 2, 3, 4]).unwrap(); + assert!(validate_direct_source(&wrong_source_path, 4).is_err()); + + let (other_store, other_base) = store(); + let other_path = frame(&other_store, "other", &[1, 2, 3, 4]); + assert!(validate_direct_source(&other_path, 4).is_err()); + drop(other_store); + let _ = fs::remove_dir_all(other_base); + + let invalid_generation = runtime_base().join(format!( + "invalid-generation-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let invalid_source = invalid_generation.join("source"); + fs::create_dir_all(&invalid_source).unwrap(); + fs::set_permissions( + &invalid_generation, + fs::Permissions::from_mode(DIRECTORY_MODE), + ) + .unwrap(); + fs::set_permissions(&invalid_source, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + let invalid_path = invalid_source.join("frame"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&invalid_path) + .unwrap(); + file.write_all(&[1, 2, 3, 4]).unwrap(); + assert!(validate_direct_source(&invalid_path, 4).is_err()); + let _ = fs::remove_dir_all(invalid_generation); + } + + #[test] + fn generation_creation_removes_dead_server_directories() { + let (store, base) = store(); + fs::create_dir_all(&base).unwrap(); + fs::set_permissions(&base, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + let stale = base.join("server-2147483647-stale"); + fs::create_dir(&stale).unwrap(); + fs::set_permissions(&stale, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap(); + + store.source_directory().unwrap(); + + assert!(!stale.exists()); + drop(store); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn replacement_after_lease_is_detected_before_fallback_copy() { + let (store, base) = store(); + let path = frame(&store, "frame", &[1, 2, 3, 4]); + let lease = store.lease(&path, 4).unwrap(); + fs::remove_file(&path).unwrap(); + let _replacement = frame(&store, "frame", &[5, 6, 7, 8]); + assert!(lease.copy_rgba().is_err()); + drop(lease); + drop(store); + let _ = fs::remove_dir_all(base); + } +} diff --git a/crates/ghostty-vt/src/png_forward_tests.rs b/crates/ghostty-vt/src/png_forward_tests.rs new file mode 100644 index 00000000..6d3904b2 --- /dev/null +++ b/crates/ghostty-vt/src/png_forward_tests.rs @@ -0,0 +1,232 @@ +use super::*; +use base64::Engine as _; + +fn fixture() -> Vec { + fixture_size(32, 16) +} + +fn fixture_size(width: u32, height: u32) -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&vec![42; (width * height * 4) as usize]) + .unwrap(); + } + bytes +} + +fn terminal() -> Terminal { + let mut terminal = Terminal::new(20, 10, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.set_kitty_png_forwarding(true).unwrap(); + terminal.resize(20, 10, 8, 16).unwrap(); + terminal +} + +#[test] +#[ignore = "manual fixed-geometry native PNG ingestion/extraction scaling profile"] +fn native_png_render_scale_profile() { + let png = fixture_size(800, 480); + let upload = format!( + "\x1b[H\x1b_Ga=T,f=100,i=7,c=60,r=20,C=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&png) + ); + for count in [1, 15] { + for forwarding in [false, true] { + let mut panes = (0..count) + .map(|_| { + let mut pane = Terminal::new(120, 40, 0).unwrap(); + pane.enable_kitty_graphics().unwrap(); + pane.set_kitty_png_forwarding(forwarding).unwrap(); + pane.resize(120, 40, 8, 16).unwrap(); + pane + }) + .collect::>(); + let mut samples = Vec::new(); + for iteration in 0..65 { + let begin = std::time::Instant::now(); + for pane in &mut panes { + pane.write(upload.as_bytes()); + let placements = pane.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!( + placements[0].data.len(), + if forwarding { png.len() } else { 800 * 480 * 4 } + ); + std::hint::black_box(placements); + } + if iteration >= 5 { + samples.push(begin.elapsed().as_micros()); + } + } + samples.sort_unstable(); + eprintln!("native PNG panes={count} forwarding={forwarding} median_us={} p95_us={} png_bytes={}", samples[30], samples[57], png.len()); + } + } +} + +#[test] +fn normal_kitty_graphics_fully_decodes_quiet_png_uploads() { + let mut terminal = Terminal::new(20, 10, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.resize(20, 10, 8, 16).unwrap(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(fixture()) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].format, KittyImageFormat::Rgba); + assert_eq!(placements[0].data, vec![42; 32 * 16 * 4]); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); +} + +#[test] +fn quiet_png_forwarding_retains_exact_payload_without_pixel_decode() { + let bytes = fixture(); + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].format, KittyImageFormat::Png); + assert_eq!(placements[0].data, bytes); + assert_eq!( + (placements[0].image_width, placements[0].image_height), + (32, 16) + ); + assert_eq!(PNG_DECODE_CALLS.get(), before); +} + +#[test] +fn placeholder_png_forwarding_and_lazy_animation_decode() { + let bytes = fixture(); + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,U=1,i=7,c=4,r=1,q=2;{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + terminal.write("\x1b[H\x1b[38;2;0;0;7m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes()); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].format, KittyImageFormat::Png); + assert_eq!(placements[0].data, bytes); + assert_eq!(PNG_DECODE_CALLS.get(), before); + let fingerprint = placements[0].data_fingerprint; + terminal.write(b"\x1b_Ga=a,i=7,q=2;\x1b\\"); + let materialized = terminal.kitty_image_placements().unwrap(); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); + assert_eq!(materialized[0].format, KittyImageFormat::Rgba); + assert_eq!(materialized[0].data, [42; 32 * 16 * 4]); + assert_ne!(materialized[0].data_fingerprint, fingerprint); +} + +#[test] +fn experimental_quiet_png_defers_crc_valid_compressed_data_errors() { + let mut bytes = fixture(); + let mut offset = 8; + loop { + let len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; + if &bytes[offset + 4..offset + 8] == b"IDAT" { + bytes[offset + 8] = 0; // Invalid zlib header, but structurally valid PNG chunks. + let mut crc = !0u32; + for &byte in &bytes[offset + 4..offset + 8 + len] { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320u32 & 0u32.wrapping_sub(crc & 1)); + } + } + bytes[offset + 8 + len..offset + 12 + len].copy_from_slice(&(!crc).to_be_bytes()); + break; + } + offset += len + 12; + } + for quiet in [0, 2] { + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q={quiet};{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + let placements = terminal.kitty_image_placements().unwrap(); + if quiet == 0 { + assert!(placements.is_empty()); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); + } else { + assert_eq!(placements[0].data, bytes); + assert_eq!(PNG_DECODE_CALLS.get(), before); + terminal.write(b"\x1b_Ga=a,i=7,q=2;\x1b\\"); + assert_eq!(PNG_DECODE_CALLS.get(), before + 1); + assert_eq!(terminal.kitty_image_placements().unwrap()[0].data, bytes); + } + } +} + +#[test] +fn response_bearing_png_and_queries_keep_full_validation() { + let bytes = fixture(); + for (action, quiet) in [('T', 0), ('T', 1), ('q', 2)] { + let mut terminal = terminal(); + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga={action},f=100,i=7,c=4,r=1,q={quiet};{}\x1b\\", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ) + .as_bytes(), + ); + assert_eq!( + PNG_DECODE_CALLS.get(), + before + 1, + "action {action}, quiet {quiet}" + ); + let placements = terminal.kitty_image_placements().unwrap(); + if action == 'q' { + assert!(placements.is_empty()); + } else { + assert_eq!(placements[0].format, KittyImageFormat::Rgba); + } + } +} + +#[test] +fn chunked_png_forwarding_inherits_quiet_mode() { + let bytes = fixture(); + let mut terminal = terminal(); + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let split = (encoded.len() / 8) * 4; + let before = PNG_DECODE_CALLS.get(); + terminal.write( + format!( + "\x1b_Ga=T,f=100,i=7,c=4,r=1,q=2,m=1;{}\x1b\\", + &encoded[..split] + ) + .as_bytes(), + ); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + terminal.write(format!("\x1b_Gm=0;{}\x1b\\", &encoded[split..]).as_bytes()); + assert_eq!(terminal.kitty_image_placements().unwrap()[0].data, bytes); + assert_eq!(PNG_DECODE_CALLS.get(), before); +} diff --git a/distribution/agent-detection/codex.toml b/distribution/agent-detection/codex.toml index 5021567a..dd275758 100644 --- a/distribution/agent-detection/codex.toml +++ b/distribution/agent-detection/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.14.1" +version = "2026.09.23.1" min_engine_version = 3 -updated_at = "2026-09-14T00:00:00Z" +updated_at = "2026-09-23T22:12:00Z" [[rules]] id = "osc_title_blocked" @@ -62,6 +62,7 @@ any = [ { contains = ["enter to submit answer"] }, { contains = ["enter to submit all"] }, { contains = ["allow command?"] }, + { contains = ["All Results", "Filesystem Only", "Plugins"] }, ] [[rules]] @@ -84,20 +85,10 @@ state = "working" priority = 500 region = "before_current_prompt_marker" visible_working = true -# Cheap negative filter before the multiline search. -contains = ["esc to interrupt"] -# Animations can hide the bullet, and queued inputs can sit below a dynamic activity label. -# Require the live timer/control suffix with no later response or interruption marker. -regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s • esc to interrupt\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+esc\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] - -[[rules]] -id = "osc_title_idle" -state = "idle" -priority = 100 -region = "osc_title" -visible_idle = true -regex = ['\S'] -not = [ - { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, - { contains = ["Action Required"] }, -] +# Support animated and reduced-motion status, including dynamic activity labels. +# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can +# sit below the status. Require the live timer suffix with no later response. +any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }] +regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] +# A failed reconnect keeps its final elapsed timer but is no longer working. +not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }] diff --git a/distribution/agent-detection/grok.toml b/distribution/agent-detection/grok.toml index 996cf377..4abcefd6 100644 --- a/distribution/agent-detection/grok.toml +++ b/distribution/agent-detection/grok.toml @@ -1,16 +1,16 @@ id = "grok" -version = "2026.07.16.1" +version = "2026.09.18.1" min_engine_version = 2 -updated_at = "2026-07-16T00:00:00Z" +updated_at = "2026-09-18T00:00:00Z" aliases = ["grok-build"] -# Evidence: Grok Build 0.2.101 source and live pane reads. +# Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads. # # Grok emits OSC 0 titles by default. Idle is "grok" or # " - grok". During a turn, the configured title gains a braille # spinner and activity text. Permission prompts add "⚠ Action Required"; # that prefix blinks while the terminal is unfocused, so visible blocker -# rules outrank the generic non-idle title rule. +# rules outrank the title spinner rule. # # Grok also emits OSC 9;4 progress on supported terminals. Herdr retains the # payload after "9;": "4;1;-1" while busy and "4;0;0" when idle. @@ -74,6 +74,16 @@ any = [ { contains = ["←/→:scope"] }, ] +# Grok 1.0.34 moves background counts above the composer and clears OSC +# progress between turns even while these commands are still running. +[[rules]] +id = "background_status_working" +state = "working" +priority = 1165 +region = "bottom_non_empty_lines(12)" +visible_working = true +line_regex = ['^\s*[○◎◉]\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?)(?:\s+·\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?))*\s+still running(?:\s+·\s+send a message to interrupt)?\s*$'] + [[rules]] id = "osc_progress_working" state = "working" @@ -93,16 +103,15 @@ not = [ { regex = ['[\x{2800}-\x{28FF}]'] }, ] -# After known idle titles are excluded, any other non-empty Grok title is -# active. OSC progress remains authoritative when a custom title omits the -# spinner but the terminal supports progress reporting. +# Title items are configurable; omitting "grok" does not imply activity. +# Require a spinner, with OSC progress covering titles that omit it. [[rules]] id = "osc_title_working" state = "working" priority = 1000 region = "osc_title" visible_working = true -regex = ['\S'] +regex = ['(?:^|\s)[\x{2801}-\x{28FF}](?:\s|$)'] [[rules]] id = "osc_progress_idle" @@ -112,10 +121,12 @@ region = "osc_progress" visible_idle = true regex = ['^4;0;0$'] +# Visible activity outranks idle OSC signals: disabling title updates can +# leave the startup "grok" title unchanged throughout a turn. [[rules]] id = "spinner_status_working" state = "working" -priority = 200 +priority = 1160 region = "whole_recent" visible_working = true line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] @@ -123,16 +134,20 @@ line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] [[rules]] id = "esc_cancel_hints_working" state = "working" -priority = 190 +priority = 1155 region = "bottom_non_empty_lines(2)" visible_working = true -contains = ["esc:cancel", "ctrl+.:shortcuts"] +contains = ["ctrl+.:shortcuts"] +any = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] # Pre-0.2.x working chrome kept for older Grok Build releases. [[rules]] id = "waiting_tool_working" state = "working" -priority = 120 +priority = 1140 region = "whole_recent" visible_working = true any = [ diff --git a/distribution/agent-detection/kiro.toml b/distribution/agent-detection/kiro.toml index 59ac0a06..9d50f932 100644 --- a/distribution/agent-detection/kiro.toml +++ b/distribution/agent-detection/kiro.toml @@ -1,63 +1,97 @@ id = "kiro" -version = "2026.08.01.1" -min_engine_version = 1 -updated_at = "2026-08-01T00:00:00Z" +version = "2026.09.19.1" +min_engine_version = 2 +updated_at = "2026-09-19T00:00:00Z" aliases = ["kiro-cli"] +[[rules]] +id = "live_prompt_idle" +state = "idle" +priority = 1100 +region = "bottom_non_empty_lines(4)" +visible_idle = true +line_regex = ['(?i)^\s*[>›]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$'] + [[rules]] id = "tool_approval" state = "blocked" -priority = 300 -region = "whole_recent" +priority = 1050 +region = "bottom_non_empty_lines(8)" visible_blocker = true -contains = ["requires approval"] any = [ - { contains = ["yes, single permission"] }, - { contains = ["trust, always allow"] }, - { contains = ["no (tab to edit)"] }, - { contains = ["esc to close"] }, + { all = [ + { regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] }, + { any = [ + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:allow|always allow|deny|always deny)\s*$', + '(?i)^\s*(?:[>❯]\s*)?allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?always allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?deny\s*$', + '(?i)^\s*(?:[>❯]\s*)?always deny\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$', + '(?i)^\s*(?:[>❯]\s*)?yes, single permission\s*$', + '(?i)^\s*(?:[>❯]\s*)?trust, always allow in this session\s*$', + '(?i)^\s*(?:[>❯]\s*)?no \(tab to edit\)\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:trust\b.*|entire tool)\s*$', + '(?i)^\s*(?:[>❯]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$', + ] }, + ] }, + ] }, ] [[rules]] -id = "subagent_approval" +id = "tool_approval_edit" state = "blocked" -priority = 290 +priority = 1045 region = "whole_recent" visible_blocker = true -contains = ["pending from subagents"] -any = [ - { contains = ["tool approval"] }, - { contains = ["tool approvals"] }, -] -all = [ - { any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] }, -] +regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>›][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z'] [[rules]] -id = "prompt_idle" -state = "idle" -priority = 200 -region = "bottom_non_empty_lines(5)" -visible_idle = true -contains = ["ask a question or describe a task", "/copy to clipboard"] -not = [ - { contains = ["kiro is working"] }, - { contains = ["esc to cancel"] }, +id = "crew_approval" +state = "blocked" +priority = 1040 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "tool approval", + "approve all pending", + "configure individually (agent monitor)", + "exit (cancel subagents)", ] [[rules]] -id = "kiro_working_marker" +id = "question_panel" +state = "blocked" +priority = 1030 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["to navigate", "to submit", "esc to cancel"] + +[[rules]] +id = "live_working_footer" state = "working" -priority = 100 -region = "whole_recent" +priority = 950 +region = "bottom_non_empty_lines(4)" visible_working = true -contains = ["kiro is working"] +contains = ["kiro is working", "type to steer", "ctrl+s to queue"] [[rules]] -id = "tool_spinner_working" +id = "osc_title_working" state = "working" -priority = 90 -region = "whole_recent" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:'] + +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 890 +region = "osc_progress" visible_working = true -contains = ["esc to cancel"] -line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}'] +regex = ['^4;3;?$'] diff --git a/distribution/latest.json b/distribution/latest.json index ac154b68..456b8145 100644 --- a/distribution/latest.json +++ b/distribution/latest.json @@ -1,23 +1,42 @@ { - "version": "0.9.0", + "version": "0.9.1", "protocol": 22, "endpoint_generation": 1, - "notes": "### Added\n- Manage Local and saved SSH machines from one Herdr window, with a combined agent list, machine-scoped navigation, notifications, and automatic reconnects. Add and manage connections with `herdr machine`; a disconnected machine does not interrupt the others. (#3670)\n- Multiple clients can now view different workspaces and tabs independently. Different tabs fit their viewing clients; when clients share a tab, the last one to interact with it controls its size. (#3526)\n- Added Muse agent detection for idle, working, approval, and question states. (#2489, thanks @ohk)\n- Sidebar text and metadata tokens can now change color, boldness, and dimming based on their values, using ordered text or numeric rules. (#3693)\n- Custom themes can now define separate light and dark color overrides when automatic theme switching is enabled. (#2324, thanks @aneym)\n- `ui.pane_borders = \"always\"` can now frame a single pane when outer borders are enabled. `\"auto\"` keeps split-only borders, `\"off\"` hides them, and existing boolean values keep working. (#3234, thanks @rsmdt)\n\n### Changed\n- Client updates can now leave compatible servers and their running agents untouched. Missing server features disable only the affected action instead of preventing connection. Servers older than endpoint generation 1 need a one-time upgrade. Replacing a remote server asks before stopping its pane processes, with No as the default answer; experimental handoff remains opt-in. (#3509)\n- The terminal UI now runs in each client, reducing redraw work in busy multi-client sessions and keeping themes, menus, copy mode, and other presentation settings local to the viewing machine. (#3487)\n- Idle terminal scrollback now uses less memory without reducing retained history or changing reads and resizing. (#3556)\n- Pane images and the graphics API are now enabled by default in compatible terminals. Set `terminal.kitty_graphics = false` to disable them; the old `experimental.kitty_graphics` setting remains accepted.\n- Closing a primary workspace with open worktree workspaces now requires explicit group intent: `workspace close --group` or `workspace.close` with `close_group: true`. Otherwise the whole group stays open. (#2874)\n- New lifecycle event subscriptions now start with live events rather than replaying retained history. API clients should subscribe before taking their initial snapshot to avoid missing changes. (#1270)\n\n### Fixed\n- Mouse selections now stay visible and copyable while terminal output continues, including with automatic copying disabled. Ctrl+C and Cmd+C copy a selection even before a delayed mouse release, and a failed copy no longer interrupts the agent. Selection highlights also remain visible when host colors are unavailable. (#3100, #2708, #3684, thanks @moret and @Pimpmuckl)\n- Wayland clipboard copies no longer freeze Herdr while `wl-copy` serves the selection. (#3014)\n- Live handoff now preserves mouse forwarding for running pane applications. (#3000, thanks @xkrogen)\n- Interrupted pane exits during host shutdown no longer replace the saved session with an empty session or a new default workspace. (#3415)\n- SSH clients whose terminal disappears now detach instead of resizing running panes to a fallback size and triggering expensive history reflow. (#3519)\n- Focusing a workspace now scrolls the sidebar to keep it visible. (#3554)\n- Removing a background worktree workspace no longer changes focus to its parent. Windows worktrees can also be removed while their agent panes are running, without a replacement shell locking the checkout again. (#3098, #3532)\n- Worktree commands can now trust a verified repository for one request with `--trust-repository`, including accessible Windows repositories owned by another SID, without changing global Git configuration. (#3044)\n- Oversized Kitty images no longer prevent smaller images from appearing, and image replacements no longer redraw or disappear one row at a time. Direct graphics stay bound to the client that owns them. (#3033, #3166, #3549, thanks @kataokatsuki)\n- Pixel mouse coordinates remain correct when pane applications reassert SGR mouse reporting. (#3295)\n- Prefix bindings such as `prefix+|` now recognize characters produced by macOS Option and custom keyboard layouts, while exact chords keep priority. Arrow navigation also works when terminals such as Alacritty attach text to special-key reports. (#3079, #3328, thanks @vlcinsky)\n- Direct terminal attaches now honor `ui.mouse_capture` and preserve multiline pastes as one paste instead of submitting each line separately. (#2992, #3054)\n- `agent prompt` now reliably sends the prompt and Enter before reporting successful submission. With `--wait`, prompts sent to a non-working agent require observed working or blocked activity, so unrelated state changes cannot complete the wait. Pending submissions fail cleanly if the terminal exits. (#3506, #3685)\n- Long Codex prompts on Windows now wait long enough for the text to arrive before submitting Enter. Expired queued submissions are rejected before typing starts. (#3187)\n- Recent pane reads now include output that has not yet scrolled off the viewport, instead of returning empty text. (#3444)\n- `pane report-agent` and `pane report-agent-session` now accept options before the pane ID and `--option=value` arguments. (#2926)\n- `herdr agent explain --file` now reports unreadable files as structured JSON rather than raw Rust errors. (#3022)\n- Running named servers now pick up detection manifests downloaded by another server without needing a restart. (#2711)\n- Agents that set their terminal title or progress once at startup no longer lose that detection signal when first recognized. (#3326, thanks @aneym)\n- Claude Code now recognizes visible turn and background-agent activity when terminal titles are unavailable, and remains working while background MCP tasks continue. An idle prompt with only a background shell running no longer stays working. (#1630, #3090, #3414)\n- Claude Code MCP questions and Bash approval prompts now stay blocked while waiting for an answer, including different option layouts and cursor positions. (#3283, #3383, #2650, #3615, thanks @caner-akca)\n- Claude Code hooks now ignore Cursor's Claude-compatible events, preventing Cursor sessions from being saved as Claude sessions. (#2832)\n- Codex no longer appears blocked because earlier output quotes a confirmation prompt, and its startup update dialog now correctly reports blocked. Explicitly resumed sessions are saved before the first prompt, so they survive a server restart. (#3301, #3632, #3517)\n- GitHub Copilot CLI now stays working while it waits for background agents. (#3291, #3403, thanks @LaneBirmingham)\n- Oh My Pi now stays working through already-scheduled continuations instead of briefly reporting idle and ending `agent wait` early. (#2851, #3122, thanks @caner-akca and @taoeffect)\n- OpenCode child-agent permission and question prompts no longer leave the parent pane stuck as blocked after work continues. (#3669, thanks @markjaquith)\n- Foreground working-directory reads now follow the foreground process-group leader rather than a descendant, keeping new panes in the intended directory. (#3270, #3386, thanks @caner-akca)\n- Oversized or unreadable Git ref files no longer cause repeated heavy status reads or make workspaces use stale ref data. (#3343, #3373, thanks @caner-akca)\n- Tab bar status commands no longer display stray ANSI escape fragments, and host palette replies are applied together to avoid redundant updates. (#3001, #3580)\n- Unix plugin panes now keep `PWD` aligned with their requested working directory unless explicitly overridden. Windows plugin panes now resolve relative commands from the plugin root and handle launch paths correctly. (#2984, #3024)\n- Plugin link handlers now receive matching OSC 8 `file://` clicks; unmatched file links still do not launch the system URL opener. (#2941)\n- Windows panes now preserve non-US shifted text, physical modified keys, Ctrl+/, and dead-key composition in Kitty keyboard applications. (#3045, #2954, #3546, #3503)\n- Windows OpenSSH sessions now receive mouse input reliably without dropped reports or escape fragments leaking into panes. Remote and LF-only multiline pastes retain their newlines and order. (#2810, #3459, #3209, #3172)\n- Windows panes now keep Cursor and bundled Pi launches detected, and PowerShell agent shims accept native arguments. Codex no longer inherits Windows Terminal identity that caused excessive repainting and scroll jumps during resume. (#3032, #3205, #3455, #3127, thanks @Pimpmuckl)\n- Antigravity hooks now run correctly on Windows, and Devin integration setup uses the correct Windows config directory. (#3348, #2724)\n- Windows installations no longer need a separately installed Visual C++ runtime. Installer paths remain discoverable in OpenSSH sessions, and malformed inherited environment values are rejected safely. (#3089, #3611, #3430)\n- Windows users whose endpoint security blocks fileless PowerShell installation can use a local `install.cmd` bootstrap with checksum-verified downloads. (#2751)\n- WSL remote sessions can now paste images from the Windows clipboard. (#3376)\n- Nix installations now fetch crates through the static CDN, avoiding download failures from the previous endpoint. (#3505)\n- The Unix installer now explains that Android/Termux is unsupported instead of installing a Linux binary that cannot run there. (#3571)\n\n### Removed\n- Removed the single-process `--no-session` mode. All terminal UI launches now attach to a background server; detach leaves panes running, while `server stop` ends the session.", + "notes": "### Added\n- Control agents, panes, workspaces, and worktrees on saved SSH machines with `herdr --machine `. Commands use the saved machine's session without needing an open Herdr window. Update Herdr on both machines to use CLI forwarding; failed remote commands never fall back to Local. (#3918)\n- Connect to Windows SSH hosts from Linux, macOS, or Windows. Interactive setup can install or update the complete Windows package after confirmation; background reconnects never install updates. (#3651, #3701, #3661, #3687, thanks @JJLiebig)\n- Edit names, filters, and search text at the cursor instead of only at the end. Herdr inputs now support character and word movement, Home/End, deletion, and familiar Ctrl+A/E/K/U/W/Y shortcuts, including Unicode text. (#1803, #3698, thanks @markjaquith)\n- Added Letta Code detection and native conversation restore, including default conversations. Its experimental integration is installed through `herdr integration install letta`, not the Settings integration list or JSON integration API. (#3106, #3107, thanks @just-cameron)\n- Hold Ctrl over a link to highlight it before opening it. Wrapped URLs and OSC 8 links remain clickable even when their beginning or end is outside the viewport. (#1282)\n- Sidebar token rules can now hide matching tokens and their separators with `hide = true`; rows disappear when no tokens remain. (#3925)\n\n### Changed\n- Workspace keyboard navigation now spans connected machines in sidebar order, with a visible highlight before Enter switches machines. Collapsed machine and worktree groups remain navigable without accidentally sending actions to the wrong machine. (#3754)\n- Experimental live handoff can now transfer more than 64 panes when the sending server includes this update. An older running server still has its old limit for the first upgrade; this does not change update installation order or make live handoff non-experimental. (#3393, #3411, thanks @kataokatsuki)\n- On Linux and macOS, terminal observers that stop accepting output are disconnected after 30 seconds without write progress. Their pane and other clients keep running; an interrupted stream may end without a final close record. (#3612)\n- Windows terminal cleanup now explicitly resets mouse-reporting modes. The remaining standalone Git Bash detach report in #3748 is still under investigation; do not treat this release as a confirmed fix for that report. (#4055, thanks @JJLiebig)\n\n### Fixed\n- Remote typing, switching, and popup interaction no longer resend the entire pane screen for small changes. Busy SSH sessions use less bandwidth, and idle attached clients avoid unnecessary redraw work. (#3769, #3745, #3822)\n- A stalled SSH machine no longer traps the client away from Local. Clicking a local workspace or agent cancels the unfinished remote switch, and a recovered remote workspace refreshes without an away-and-back selection. Stale screens from before a reconnect are not reused. (#3903, #3842)\n- Idle SSH connections use less CPU without losing final output. Repeated connection failures back off instead of reconnecting rapidly, and supported idle bridges clean up without stopping remote panes. (#3728, #4083)\n- Large pastes no longer disconnect the client with an output-queue error. (#3833)\n- SSH setup now shows authentication instructions and useful errors, including Tailscale login URLs and host-key failures, rather than appearing to hang or only reporting a lost connection. `machine add` also accepts options before the SSH target. (#3606, #3731, #3883)\n- Machine and worktree groups collapse correctly without changing the selected workspace. The sidebar respects workspace row gaps, stays stable during resizing, and reveals newly focused workspaces. (#3778, #3738, #3817)\n- The agent list keeps its scroll position when switching machines. Current-workspace and current-tab filters distinguish identical IDs on different machines. (#3937, #4101, #3732, thanks @minatoaquaMK2)\n- Background machine activation no longer resizes another client's focused pane. Window titles follow each client's own view rather than another client's selection. (#3744, #4091, #4130, thanks @JJLiebig)\n- `agent focus` and `pane move --focus` now move attached clients to the requested pane. Moving without focus no longer leaves a client following a deleted tab or later unrelated focus changes. (#3760, #4153, #4171, thanks @minatoaquaMK2)\n- Creating a worktree focuses its new workspace again. Partially failed worktree removals can be retried, and worktrees with submodules offer an explicit forced-removal choice rather than failing without a recovery action. (#3766, #3314, #1797)\n- Temporary worktree-action error banners expire instead of remaining on screen indefinitely. (#1827, #4063, thanks @JJLiebig)\n- The last tab remains fully visible when the tab strip overflows, and the session navigator once again draws connected tree branches. (#4151, #3843)\n- Muted sidebar and tab labels remain readable on dark themes, including Windows Terminal. (#2692, #4062, thanks @JJLiebig)\n- Mouse drag and copy-mode selections stay active while output continues, even when part of the selection is outside the viewport. Copying uses the current text in the selected range. Selection repainting also avoids building up a backlog of mouse movements. (#3841, #3895)\n- Double-click selections remain visible with automatic copy disabled. Holding the second click and dragging extends by whole words, and token selection stops at CJK punctuation. Host selection colors remain visible on transparent backgrounds. (#3847, #1836, #3706, #3798)\n- Scrolling no longer combines mismatched terminal snapshots or leaves stale wrapped rows in fullscreen applications such as Neovim. Alternate-screen history reads preserve content across changing headers and scrollbars. (#3900, #3329, #3979)\n- Local Kitty graphics keep their direct transport after the first image. Popups and notifications hide only overlapping image placements; uncovered images remain visible. (#3785, #4081)\n- Pixel mouse coordinates work independently of image rendering and use the terminal's integer cell size, preventing clicks from drifting across the screen. (#3295, #4140, thanks @JJLiebig)\n- Function keys F1\u2013F12 are recognized in Kitty keyboard reports, and F1\u2013F4 also work with parameterized reports from terminals such as foot. (#1809, #2378, #4067, thanks @ashokDevs and @JJLiebig)\n- WezTerm control-key reports now preserve Enter, Backspace, Tab, and Escape. Cmd/Super-modified input reaches pane applications with its modifier intact. Modified Enter stays compatible with panes that have not requested enhanced key encoding. (#3589, #3710, #4103, #4110, thanks @minatoaquaMK2)\n- Mouse reports split across delayed reads no longer leak their trailing bytes into pane input. Clients reassert mouse reporting after terminal reconnects. (#3911, #4079, thanks @JJLiebig)\n- Saved sessions that cannot be loaded are preserved before replacement, rather than silently overwritten. Recovery copies are kept in `session-backups/`; failed preservation leaves the original untouched. (#4125)\n- Deleting a session now requires its exact name, including case, on case-insensitive filesystems. Names beginning with a hyphen can be passed after `--`. (#3819, #4233, #3220, thanks @minatoaquaMK2)\n- Newly started macOS servers retain access to user lookup and DNS across logout. Existing affected servers need a full session restart; live handoff cannot repair their inherited service context. (#4100)\n- Linux process discovery stays responsive around stuck WSL agents and avoids repeatedly scanning an ever-growing process tree. (#2179, #3621, #3674, thanks @dark2momo and @caner-akca)\n- Linux desktop notifications identify Herdr as their source. (#3638)\n- `pane split` without a target now splits the calling pane when invoked inside Herdr, rather than another client's focused pane. Explicit targets keep their existing behavior. (#4123)\n- Live agent names survive temporary process-detection uncertainty instead of leaving an active agent unreachable by name. (#3225, #3574, thanks @caner-akca)\n- Windows Codex prompts reliably submit after pasted text instead of losing Enter, including long prompts. (#3187, #3961, thanks @JJLiebig)\n- OpenCode V2 reports lifecycle state from the pane displaying the conversation, so shared servers do not mix up panes. Completed and interrupted turns return to idle, while pending permissions, forms, and failed executions remain blocked. V1 remains supported; restart OpenCode after installing the updated integration. (#3757, thanks @markjaquith)\n- Codex stays working with static titles, animations disabled, and queued follow-ups. Composer sparkles and old confirmation text no longer make an idle pane appear blocked. (#4092, #4099, #3988, thanks @minatoaquaMK2)\n- Claude Code's Unicode activity spinner and Pi's working border are recognized correctly. Cline launcher detection and idle-state reporting are more reliable. (#3949, #3629, #2396)\n- Pi records native session paths on Windows. Nested OMP processes no longer replace the parent pane's resumable session, and Windows Kimi npm shims are recognized. (#3726, #2593, #3994, #3317, #4053, thanks @JJLiebig)\n- Grok updates its restore reference after `/new`, and its imported Claude-compatible hooks no longer overwrite that reference. Reinstall the Claude integration to adopt the narrower hook matching. (#2681, #4018)\n- Integration configuration updates preserve existing files when writes fail. Hermes installation also preserves valid YAML when enabled plugins use an inline list. (#3970, #3839, #4056, thanks @JJLiebig)\n- Plugin registry writes preserve symlinks. Plugins keep using the running server's compatible binary after a client-only update, and manual pane navigation emits focus events again. (#3922, #3824, #4077)\n- Config files containing a UTF-8 BOM load and save correctly. The default config places `ui.accent` in the right table. (#3678, #3962, #2697, thanks @JJLiebig)\n- Windows local and SSH input preserves Alt combinations, Ctrl+S, and enhanced keyboard text instead of dropping keys or inserting garbage. Remote dead-key input no longer adds an extra base character. (#3702, #3910, #3932, #3948, #4133, #4148, thanks @JJLiebig)\n- Windows mouse capture preserves SGR coordinates, including past column 95, and reattaching restores mouse reporting. Legacy SSH mouse reports no longer produce phantom characters or block subsequent input. (#4155, #3735, #4080, thanks @JJLiebig)\n- Git Bash SSH connections honor host aliases from the user's SSH config. (#3947, #4054, thanks @JJLiebig)\n- Windows login-mode panes honor the configured shell and keep PowerShell working-directory reports current. Pane launch paths use consistent directory casing. (#1445, #4060, #4065, #4199, thanks @JJLiebig)", "assets": { - "linux-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.0/herdr-linux-x86_64", - "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.0/herdr-linux-aarch64", - "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.0/herdr-macos-x86_64", - "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.0/herdr-macos-aarch64", - "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.0/herdr-windows-x86_64.zip" + "linux-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-linux-x86_64", + "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-linux-aarch64", + "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-macos-x86_64", + "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-macos-aarch64", + "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-windows-x86_64.zip" }, "sha256": { - "linux-x86_64": "4fa1a01158dd8043da92d31b270780b0dcc10603038d9b61cac4d81ab63fb71f", - "linux-aarch64": "9c8db20fb7e7427b138d5367113f1621ffd319f2f65d6f009e2594029115f0d2", - "macos-x86_64": "d0c920b2a126a74809fa1491411c9a097a44786cac9c2ca51b818a995581cf16", - "macos-aarch64": "32b53df09872628059c789a69f02a6b8e29e14ddf26711421f3463f70c1aef17", - "windows-x86_64": "b4508c445de1c1a68c760a01735da2aba2fa214b2aafd4b07f732e49b2a64b11" + "linux-x86_64": "2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7", + "linux-aarch64": "f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e", + "macos-x86_64": "053be0639935fe54ab5efbdb46651054e4f6a753a5b43153c88bd6912bce1e94", + "macos-aarch64": "5fc7a7e7adfaca56fa80aa89dcb025693357268dab8285b9ce2d08a2313c89de", + "windows-x86_64": "04ce380cac5af27bfcf75d0951ac49b7afe4c984aee8852985806d4f71f93a6e" }, "releases": { + "0.9.1": { + "notes": "### Added\n- Control agents, panes, workspaces, and worktrees on saved SSH machines with `herdr --machine `. Commands use the saved machine's session without needing an open Herdr window. Update Herdr on both machines to use CLI forwarding; failed remote commands never fall back to Local. (#3918)\n- Connect to Windows SSH hosts from Linux, macOS, or Windows. Interactive setup can install or update the complete Windows package after confirmation; background reconnects never install updates. (#3651, #3701, #3661, #3687, thanks @JJLiebig)\n- Edit names, filters, and search text at the cursor instead of only at the end. Herdr inputs now support character and word movement, Home/End, deletion, and familiar Ctrl+A/E/K/U/W/Y shortcuts, including Unicode text. (#1803, #3698, thanks @markjaquith)\n- Added Letta Code detection and native conversation restore, including default conversations. Its experimental integration is installed through `herdr integration install letta`, not the Settings integration list or JSON integration API. (#3106, #3107, thanks @just-cameron)\n- Hold Ctrl over a link to highlight it before opening it. Wrapped URLs and OSC 8 links remain clickable even when their beginning or end is outside the viewport. (#1282)\n- Sidebar token rules can now hide matching tokens and their separators with `hide = true`; rows disappear when no tokens remain. (#3925)\n\n### Changed\n- Workspace keyboard navigation now spans connected machines in sidebar order, with a visible highlight before Enter switches machines. Collapsed machine and worktree groups remain navigable without accidentally sending actions to the wrong machine. (#3754)\n- Experimental live handoff can now transfer more than 64 panes when the sending server includes this update. An older running server still has its old limit for the first upgrade; this does not change update installation order or make live handoff non-experimental. (#3393, #3411, thanks @kataokatsuki)\n- On Linux and macOS, terminal observers that stop accepting output are disconnected after 30 seconds without write progress. Their pane and other clients keep running; an interrupted stream may end without a final close record. (#3612)\n- Windows terminal cleanup now explicitly resets mouse-reporting modes. The remaining standalone Git Bash detach report in #3748 is still under investigation; do not treat this release as a confirmed fix for that report. (#4055, thanks @JJLiebig)\n\n### Fixed\n- Remote typing, switching, and popup interaction no longer resend the entire pane screen for small changes. Busy SSH sessions use less bandwidth, and idle attached clients avoid unnecessary redraw work. (#3769, #3745, #3822)\n- A stalled SSH machine no longer traps the client away from Local. Clicking a local workspace or agent cancels the unfinished remote switch, and a recovered remote workspace refreshes without an away-and-back selection. Stale screens from before a reconnect are not reused. (#3903, #3842)\n- Idle SSH connections use less CPU without losing final output. Repeated connection failures back off instead of reconnecting rapidly, and supported idle bridges clean up without stopping remote panes. (#3728, #4083)\n- Large pastes no longer disconnect the client with an output-queue error. (#3833)\n- SSH setup now shows authentication instructions and useful errors, including Tailscale login URLs and host-key failures, rather than appearing to hang or only reporting a lost connection. `machine add` also accepts options before the SSH target. (#3606, #3731, #3883)\n- Machine and worktree groups collapse correctly without changing the selected workspace. The sidebar respects workspace row gaps, stays stable during resizing, and reveals newly focused workspaces. (#3778, #3738, #3817)\n- The agent list keeps its scroll position when switching machines. Current-workspace and current-tab filters distinguish identical IDs on different machines. (#3937, #4101, #3732, thanks @minatoaquaMK2)\n- Background machine activation no longer resizes another client's focused pane. Window titles follow each client's own view rather than another client's selection. (#3744, #4091, #4130, thanks @JJLiebig)\n- `agent focus` and `pane move --focus` now move attached clients to the requested pane. Moving without focus no longer leaves a client following a deleted tab or later unrelated focus changes. (#3760, #4153, #4171, thanks @minatoaquaMK2)\n- Creating a worktree focuses its new workspace again. Partially failed worktree removals can be retried, and worktrees with submodules offer an explicit forced-removal choice rather than failing without a recovery action. (#3766, #3314, #1797)\n- Temporary worktree-action error banners expire instead of remaining on screen indefinitely. (#1827, #4063, thanks @JJLiebig)\n- The last tab remains fully visible when the tab strip overflows, and the session navigator once again draws connected tree branches. (#4151, #3843)\n- Muted sidebar and tab labels remain readable on dark themes, including Windows Terminal. (#2692, #4062, thanks @JJLiebig)\n- Mouse drag and copy-mode selections stay active while output continues, even when part of the selection is outside the viewport. Copying uses the current text in the selected range. Selection repainting also avoids building up a backlog of mouse movements. (#3841, #3895)\n- Double-click selections remain visible with automatic copy disabled. Holding the second click and dragging extends by whole words, and token selection stops at CJK punctuation. Host selection colors remain visible on transparent backgrounds. (#3847, #1836, #3706, #3798)\n- Scrolling no longer combines mismatched terminal snapshots or leaves stale wrapped rows in fullscreen applications such as Neovim. Alternate-screen history reads preserve content across changing headers and scrollbars. (#3900, #3329, #3979)\n- Local Kitty graphics keep their direct transport after the first image. Popups and notifications hide only overlapping image placements; uncovered images remain visible. (#3785, #4081)\n- Pixel mouse coordinates work independently of image rendering and use the terminal's integer cell size, preventing clicks from drifting across the screen. (#3295, #4140, thanks @JJLiebig)\n- Function keys F1\u2013F12 are recognized in Kitty keyboard reports, and F1\u2013F4 also work with parameterized reports from terminals such as foot. (#1809, #2378, #4067, thanks @ashokDevs and @JJLiebig)\n- WezTerm control-key reports now preserve Enter, Backspace, Tab, and Escape. Cmd/Super-modified input reaches pane applications with its modifier intact. Modified Enter stays compatible with panes that have not requested enhanced key encoding. (#3589, #3710, #4103, #4110, thanks @minatoaquaMK2)\n- Mouse reports split across delayed reads no longer leak their trailing bytes into pane input. Clients reassert mouse reporting after terminal reconnects. (#3911, #4079, thanks @JJLiebig)\n- Saved sessions that cannot be loaded are preserved before replacement, rather than silently overwritten. Recovery copies are kept in `session-backups/`; failed preservation leaves the original untouched. (#4125)\n- Deleting a session now requires its exact name, including case, on case-insensitive filesystems. Names beginning with a hyphen can be passed after `--`. (#3819, #4233, #3220, thanks @minatoaquaMK2)\n- Newly started macOS servers retain access to user lookup and DNS across logout. Existing affected servers need a full session restart; live handoff cannot repair their inherited service context. (#4100)\n- Linux process discovery stays responsive around stuck WSL agents and avoids repeatedly scanning an ever-growing process tree. (#2179, #3621, #3674, thanks @dark2momo and @caner-akca)\n- Linux desktop notifications identify Herdr as their source. (#3638)\n- `pane split` without a target now splits the calling pane when invoked inside Herdr, rather than another client's focused pane. Explicit targets keep their existing behavior. (#4123)\n- Live agent names survive temporary process-detection uncertainty instead of leaving an active agent unreachable by name. (#3225, #3574, thanks @caner-akca)\n- Windows Codex prompts reliably submit after pasted text instead of losing Enter, including long prompts. (#3187, #3961, thanks @JJLiebig)\n- OpenCode V2 reports lifecycle state from the pane displaying the conversation, so shared servers do not mix up panes. Completed and interrupted turns return to idle, while pending permissions, forms, and failed executions remain blocked. V1 remains supported; restart OpenCode after installing the updated integration. (#3757, thanks @markjaquith)\n- Codex stays working with static titles, animations disabled, and queued follow-ups. Composer sparkles and old confirmation text no longer make an idle pane appear blocked. (#4092, #4099, #3988, thanks @minatoaquaMK2)\n- Claude Code's Unicode activity spinner and Pi's working border are recognized correctly. Cline launcher detection and idle-state reporting are more reliable. (#3949, #3629, #2396)\n- Pi records native session paths on Windows. Nested OMP processes no longer replace the parent pane's resumable session, and Windows Kimi npm shims are recognized. (#3726, #2593, #3994, #3317, #4053, thanks @JJLiebig)\n- Grok updates its restore reference after `/new`, and its imported Claude-compatible hooks no longer overwrite that reference. Reinstall the Claude integration to adopt the narrower hook matching. (#2681, #4018)\n- Integration configuration updates preserve existing files when writes fail. Hermes installation also preserves valid YAML when enabled plugins use an inline list. (#3970, #3839, #4056, thanks @JJLiebig)\n- Plugin registry writes preserve symlinks. Plugins keep using the running server's compatible binary after a client-only update, and manual pane navigation emits focus events again. (#3922, #3824, #4077)\n- Config files containing a UTF-8 BOM load and save correctly. The default config places `ui.accent` in the right table. (#3678, #3962, #2697, thanks @JJLiebig)\n- Windows local and SSH input preserves Alt combinations, Ctrl+S, and enhanced keyboard text instead of dropping keys or inserting garbage. Remote dead-key input no longer adds an extra base character. (#3702, #3910, #3932, #3948, #4133, #4148, thanks @JJLiebig)\n- Windows mouse capture preserves SGR coordinates, including past column 95, and reattaching restores mouse reporting. Legacy SSH mouse reports no longer produce phantom characters or block subsequent input. (#4155, #3735, #4080, thanks @JJLiebig)\n- Git Bash SSH connections honor host aliases from the user's SSH config. (#3947, #4054, thanks @JJLiebig)\n- Windows login-mode panes honor the configured shell and keep PowerShell working-directory reports current. Pane launch paths use consistent directory casing. (#1445, #4060, #4065, #4199, thanks @JJLiebig)", + "protocol": 22, + "endpoint_generation": 1, + "assets": { + "linux-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-linux-x86_64", + "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-linux-aarch64", + "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-macos-x86_64", + "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-macos-aarch64", + "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-windows-x86_64.zip" + }, + "sha256": { + "linux-x86_64": "2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7", + "linux-aarch64": "f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e", + "macos-x86_64": "053be0639935fe54ab5efbdb46651054e4f6a753a5b43153c88bd6912bce1e94", + "macos-aarch64": "5fc7a7e7adfaca56fa80aa89dcb025693357268dab8285b9ce2d08a2313c89de", + "windows-x86_64": "04ce380cac5af27bfcf75d0951ac49b7afe4c984aee8852985806d4f71f93a6e" + } + }, "0.9.0": { "notes": "### Added\n- Manage Local and saved SSH machines from one Herdr window, with a combined agent list, machine-scoped navigation, notifications, and automatic reconnects. Add and manage connections with `herdr machine`; a disconnected machine does not interrupt the others. (#3670)\n- Multiple clients can now view different workspaces and tabs independently. Different tabs fit their viewing clients; when clients share a tab, the last one to interact with it controls its size. (#3526)\n- Added Muse agent detection for idle, working, approval, and question states. (#2489, thanks @ohk)\n- Sidebar text and metadata tokens can now change color, boldness, and dimming based on their values, using ordered text or numeric rules. (#3693)\n- Custom themes can now define separate light and dark color overrides when automatic theme switching is enabled. (#2324, thanks @aneym)\n- `ui.pane_borders = \"always\"` can now frame a single pane when outer borders are enabled. `\"auto\"` keeps split-only borders, `\"off\"` hides them, and existing boolean values keep working. (#3234, thanks @rsmdt)\n\n### Changed\n- Client updates can now leave compatible servers and their running agents untouched. Missing server features disable only the affected action instead of preventing connection. Servers older than endpoint generation 1 need a one-time upgrade. Replacing a remote server asks before stopping its pane processes, with No as the default answer; experimental handoff remains opt-in. (#3509)\n- The terminal UI now runs in each client, reducing redraw work in busy multi-client sessions and keeping themes, menus, copy mode, and other presentation settings local to the viewing machine. (#3487)\n- Idle terminal scrollback now uses less memory without reducing retained history or changing reads and resizing. (#3556)\n- Pane images and the graphics API are now enabled by default in compatible terminals. Set `terminal.kitty_graphics = false` to disable them; the old `experimental.kitty_graphics` setting remains accepted.\n- Closing a primary workspace with open worktree workspaces now requires explicit group intent: `workspace close --group` or `workspace.close` with `close_group: true`. Otherwise the whole group stays open. (#2874)\n- New lifecycle event subscriptions now start with live events rather than replaying retained history. API clients should subscribe before taking their initial snapshot to avoid missing changes. (#1270)\n\n### Fixed\n- Mouse selections now stay visible and copyable while terminal output continues, including with automatic copying disabled. Ctrl+C and Cmd+C copy a selection even before a delayed mouse release, and a failed copy no longer interrupts the agent. Selection highlights also remain visible when host colors are unavailable. (#3100, #2708, #3684, thanks @moret and @Pimpmuckl)\n- Wayland clipboard copies no longer freeze Herdr while `wl-copy` serves the selection. (#3014)\n- Live handoff now preserves mouse forwarding for running pane applications. (#3000, thanks @xkrogen)\n- Interrupted pane exits during host shutdown no longer replace the saved session with an empty session or a new default workspace. (#3415)\n- SSH clients whose terminal disappears now detach instead of resizing running panes to a fallback size and triggering expensive history reflow. (#3519)\n- Focusing a workspace now scrolls the sidebar to keep it visible. (#3554)\n- Removing a background worktree workspace no longer changes focus to its parent. Windows worktrees can also be removed while their agent panes are running, without a replacement shell locking the checkout again. (#3098, #3532)\n- Worktree commands can now trust a verified repository for one request with `--trust-repository`, including accessible Windows repositories owned by another SID, without changing global Git configuration. (#3044)\n- Oversized Kitty images no longer prevent smaller images from appearing, and image replacements no longer redraw or disappear one row at a time. Direct graphics stay bound to the client that owns them. (#3033, #3166, #3549, thanks @kataokatsuki)\n- Pixel mouse coordinates remain correct when pane applications reassert SGR mouse reporting. (#3295)\n- Prefix bindings such as `prefix+|` now recognize characters produced by macOS Option and custom keyboard layouts, while exact chords keep priority. Arrow navigation also works when terminals such as Alacritty attach text to special-key reports. (#3079, #3328, thanks @vlcinsky)\n- Direct terminal attaches now honor `ui.mouse_capture` and preserve multiline pastes as one paste instead of submitting each line separately. (#2992, #3054)\n- `agent prompt` now reliably sends the prompt and Enter before reporting successful submission. With `--wait`, prompts sent to a non-working agent require observed working or blocked activity, so unrelated state changes cannot complete the wait. Pending submissions fail cleanly if the terminal exits. (#3506, #3685)\n- Long Codex prompts on Windows now wait long enough for the text to arrive before submitting Enter. Expired queued submissions are rejected before typing starts. (#3187)\n- Recent pane reads now include output that has not yet scrolled off the viewport, instead of returning empty text. (#3444)\n- `pane report-agent` and `pane report-agent-session` now accept options before the pane ID and `--option=value` arguments. (#2926)\n- `herdr agent explain --file` now reports unreadable files as structured JSON rather than raw Rust errors. (#3022)\n- Running named servers now pick up detection manifests downloaded by another server without needing a restart. (#2711)\n- Agents that set their terminal title or progress once at startup no longer lose that detection signal when first recognized. (#3326, thanks @aneym)\n- Claude Code now recognizes visible turn and background-agent activity when terminal titles are unavailable, and remains working while background MCP tasks continue. An idle prompt with only a background shell running no longer stays working. (#1630, #3090, #3414)\n- Claude Code MCP questions and Bash approval prompts now stay blocked while waiting for an answer, including different option layouts and cursor positions. (#3283, #3383, #2650, #3615, thanks @caner-akca)\n- Claude Code hooks now ignore Cursor's Claude-compatible events, preventing Cursor sessions from being saved as Claude sessions. (#2832)\n- Codex no longer appears blocked because earlier output quotes a confirmation prompt, and its startup update dialog now correctly reports blocked. Explicitly resumed sessions are saved before the first prompt, so they survive a server restart. (#3301, #3632, #3517)\n- GitHub Copilot CLI now stays working while it waits for background agents. (#3291, #3403, thanks @LaneBirmingham)\n- Oh My Pi now stays working through already-scheduled continuations instead of briefly reporting idle and ending `agent wait` early. (#2851, #3122, thanks @caner-akca and @taoeffect)\n- OpenCode child-agent permission and question prompts no longer leave the parent pane stuck as blocked after work continues. (#3669, thanks @markjaquith)\n- Foreground working-directory reads now follow the foreground process-group leader rather than a descendant, keeping new panes in the intended directory. (#3270, #3386, thanks @caner-akca)\n- Oversized or unreadable Git ref files no longer cause repeated heavy status reads or make workspaces use stale ref data. (#3343, #3373, thanks @caner-akca)\n- Tab bar status commands no longer display stray ANSI escape fragments, and host palette replies are applied together to avoid redundant updates. (#3001, #3580)\n- Unix plugin panes now keep `PWD` aligned with their requested working directory unless explicitly overridden. Windows plugin panes now resolve relative commands from the plugin root and handle launch paths correctly. (#2984, #3024)\n- Plugin link handlers now receive matching OSC 8 `file://` clicks; unmatched file links still do not launch the system URL opener. (#2941)\n- Windows panes now preserve non-US shifted text, physical modified keys, Ctrl+/, and dead-key composition in Kitty keyboard applications. (#3045, #2954, #3546, #3503)\n- Windows OpenSSH sessions now receive mouse input reliably without dropped reports or escape fragments leaking into panes. Remote and LF-only multiline pastes retain their newlines and order. (#2810, #3459, #3209, #3172)\n- Windows panes now keep Cursor and bundled Pi launches detected, and PowerShell agent shims accept native arguments. Codex no longer inherits Windows Terminal identity that caused excessive repainting and scroll jumps during resume. (#3032, #3205, #3455, #3127, thanks @Pimpmuckl)\n- Antigravity hooks now run correctly on Windows, and Devin integration setup uses the correct Windows config directory. (#3348, #2724)\n- Windows installations no longer need a separately installed Visual C++ runtime. Installer paths remain discoverable in OpenSSH sessions, and malformed inherited environment values are rejected safely. (#3089, #3611, #3430)\n- Windows users whose endpoint security blocks fileless PowerShell installation can use a local `install.cmd` bootstrap with checksum-verified downloads. (#2751)\n- WSL remote sessions can now paste images from the Windows clipboard. (#3376)\n- Nix installations now fetch crates through the static CDN, avoiding download failures from the previous endpoint. (#3505)\n- The Unix installer now explains that Android/Termux is unsupported instead of installing a Linux binary that cannot run there. (#3571)\n\n### Removed\n- Removed the single-process `--no-session` mode. All terminal UI launches now attach to a background server; detach leaves panes running, while `server stop` ends the session.", "protocol": 22, diff --git a/distribution/preview.json b/distribution/preview.json index cf16e1dd..b609cf1f 100644 --- a/distribution/preview.json +++ b/distribution/preview.json @@ -1,37 +1,130 @@ { "schema_version": 1, "channel": "preview", - "base_version": "0.9.0", - "build_id": "2026-09-08-62431dbd033b", - "commit": "62431dbd033bf1be2fe87fbc526df28084214e49", - "built_at": "2026-09-07T21:49:44Z", + "base_version": "0.9.1", + "build_id": "2026-09-28-80c0c07250d2", + "commit": "80c0c07250d22f69d3fa05cb1700302d66180eb8", + "built_at": "2026-09-28T13:31:32Z", "protocol": 22, "endpoint_generation": 1, - "notes": "Preview build 2026-09-08-62431dbd033b\n\nBuilt from `62431dbd033b` on `master`.\nBase stable: v0.9.0\nCompare: https://github.com/herdrdev/herdr/compare/v0.9.0...62431dbd033bf1be2fe87fbc526df28084214e49\n\n### Fixed\n- Reduce idle ssh cpu without dropping final output (#3728)", + "notes": "Preview build 2026-09-28-80c0c07250d2\n\n[View changes](https://github.com/herdrdev/herdr/compare/0ff0f27e222633c97ba4291f6b9be4137002ca84...80c0c07250d22f69d3fa05cb1700302d66180eb8)", "assets": { "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-08-62431dbd033b/herdr-linux-x86_64", - "sha256": "3f56cab5d1d27b87f6cd2addfa3809bf48a58b345b13746419d1b8e951e0667d" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-linux-x86_64", + "sha256": "6ffe6a33b407657c83675ac78e1fe0ca1c2fcea5b87d6322bc4a1e7bb835ca64" }, "linux-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-08-62431dbd033b/herdr-linux-aarch64", - "sha256": "47f075a0531ab6c36ec8f3144e634ea02c1307a1de405e29db3fd4d80efeb068" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-linux-aarch64", + "sha256": "9b7a37299ff34522202acf565a31c757dc72c05b6494bb2963479e20e71004c9" }, "macos-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-08-62431dbd033b/herdr-macos-x86_64", - "sha256": "6b68aaf84ad62198773d9a710dea3d364dbd6c1ff65abdff8eb6e5a71a57830d" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-macos-x86_64", + "sha256": "9a071247bfb1ca075f2a1c8f4645870eaa1a1500db8248e4c4e386dd3befcc07" }, "macos-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-08-62431dbd033b/herdr-macos-aarch64", - "sha256": "d6b6ad08961e7b491fc19a4cd8570f0231734672cfc4d6c0db6327437ac11065" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-macos-aarch64", + "sha256": "6b2fa7088721eb7ca715401e90a1cfc25bbf53ff86ab84e19302877fcf0fbf28" }, "windows-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-08-62431dbd033b/herdr-windows-x86_64.zip", - "sha256": "5904ceb4fdb3aa4c377f88ee8d2676bb39fcf62c6a8f034e6147cf21a98e6cb0", + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-windows-x86_64.zip", + "sha256": "e688bdc81cf4c8c608cca5ed7528626cf82c8ddf1eaa8e35274b9fc6e202be1c", "format": "zip" } }, "builds": { + "2026-09-28-80c0c07250d2": { + "base_version": "0.9.1", + "commit": "80c0c07250d22f69d3fa05cb1700302d66180eb8", + "built_at": "2026-09-28T13:31:32Z", + "protocol": 22, + "endpoint_generation": 1, + "tag": "preview-2026-09-28-80c0c07250d2", + "assets": { + "linux-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-linux-x86_64", + "sha256": "6ffe6a33b407657c83675ac78e1fe0ca1c2fcea5b87d6322bc4a1e7bb835ca64" + }, + "linux-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-linux-aarch64", + "sha256": "9b7a37299ff34522202acf565a31c757dc72c05b6494bb2963479e20e71004c9" + }, + "macos-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-macos-x86_64", + "sha256": "9a071247bfb1ca075f2a1c8f4645870eaa1a1500db8248e4c4e386dd3befcc07" + }, + "macos-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-macos-aarch64", + "sha256": "6b2fa7088721eb7ca715401e90a1cfc25bbf53ff86ab84e19302877fcf0fbf28" + }, + "windows-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-windows-x86_64.zip", + "sha256": "e688bdc81cf4c8c608cca5ed7528626cf82c8ddf1eaa8e35274b9fc6e202be1c", + "format": "zip" + } + } + }, + "2026-09-21-0ff0f27e2226": { + "base_version": "0.9.1", + "commit": "0ff0f27e222633c97ba4291f6b9be4137002ca84", + "built_at": "2026-09-20T21:32:22Z", + "protocol": 22, + "endpoint_generation": 1, + "tag": "preview-2026-09-21-0ff0f27e2226", + "assets": { + "linux-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-21-0ff0f27e2226/herdr-linux-x86_64", + "sha256": "70a91903c62630616c07786b955adb513897889ffe77946bf8e29defc7b9d309" + }, + "linux-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-21-0ff0f27e2226/herdr-linux-aarch64", + "sha256": "da11bf1fbcc07950962788f251220118dbb693585d7bb1eac8a4bb630a731a18" + }, + "macos-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-21-0ff0f27e2226/herdr-macos-x86_64", + "sha256": "29837230a907dd01d684f54646500caee8ed24b667f8965f975ebd70f9df693d" + }, + "macos-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-21-0ff0f27e2226/herdr-macos-aarch64", + "sha256": "203d7b29599a8bafaf94ba476912e7173cac2fb73d2988492ae9abe4e0fb44da" + }, + "windows-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-21-0ff0f27e2226/herdr-windows-x86_64.zip", + "sha256": "0bfd610f32becb5f299bb7bbcb48e4e6d07c1f57e41601ff3c0d7c99600b586d", + "format": "zip" + } + } + }, + "2026-09-16-2c29fb29e302": { + "base_version": "0.9.0", + "commit": "2c29fb29e30220bc48d783af90ba8f53b81fd96c", + "built_at": "2026-09-16T16:22:39Z", + "protocol": 22, + "endpoint_generation": 1, + "tag": "preview-2026-09-16-2c29fb29e302", + "assets": { + "linux-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-16-2c29fb29e302/herdr-linux-x86_64", + "sha256": "898c10e7004bafd96a5eb0cbdf417bd10958dae3b436d0249b9d70885653c30c" + }, + "linux-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-16-2c29fb29e302/herdr-linux-aarch64", + "sha256": "b378263788476bff3815f404695cd73251e236626d215d1e78adeb98641ff0ca" + }, + "macos-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-16-2c29fb29e302/herdr-macos-x86_64", + "sha256": "07f9c1c6be2bb95eb49778ff34bf81cce4a67c9de4cf228b3252901d9de97b45" + }, + "macos-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-16-2c29fb29e302/herdr-macos-aarch64", + "sha256": "398ab746369a8573a65fb42b1348df2bf31e22cd4f7d676fca8fca5fcb99a5a4" + }, + "windows-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-16-2c29fb29e302/herdr-windows-x86_64.zip", + "sha256": "32bb80b9da79b8ab2d8523a340cb50529ca21835128a2bf8ab5ece954f48b8de", + "format": "zip" + } + } + }, "2026-09-08-62431dbd033b": { "base_version": "0.9.0", "commit": "62431dbd033bf1be2fe87fbc526df28084214e49", @@ -812,81 +905,6 @@ "sha256": "ba2ac2ba89c46a328bb87f942cac7bc18d6d91c23f81b51b7eef8e55cc488fdb" } } - }, - "2026-06-03-5b2fce600f76": { - "base_version": "0.6.6", - "commit": "5b2fce600f7688059e8ae245da7c6ffc561bb92a", - "built_at": "2026-06-03T12:07:53Z", - "protocol": 12, - "tag": "preview-2026-06-03-5b2fce600f76", - "assets": { - "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-5b2fce600f76/herdr-linux-x86_64", - "sha256": "c4575cf293f39d72dac0dd6c9618f7e7a46d2b38ebbb7dcbe55d8ebed669866b" - }, - "linux-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-5b2fce600f76/herdr-linux-aarch64", - "sha256": "20e0b04335f6fb1572eee277b836146c4d4d7cf9d053978fc9e7194ce7ec13df" - }, - "macos-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-5b2fce600f76/herdr-macos-x86_64", - "sha256": "607c849e721802d3c75e0b6ba677be2baaa2239b5251ab370cb6457231d1ca53" - }, - "macos-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-5b2fce600f76/herdr-macos-aarch64", - "sha256": "29f3979e08420a6182f9dd0925a15d101dcbbb1eb75948cdf58f927b9f319da8" - } - } - }, - "2026-06-03-fc82cbad2179": { - "base_version": "0.6.6", - "commit": "fc82cbad21791ed58c08905469055eb9357cd350", - "built_at": "2026-06-03T04:35:19Z", - "protocol": 12, - "tag": "preview-2026-06-03-fc82cbad2179", - "assets": { - "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-fc82cbad2179/herdr-linux-x86_64", - "sha256": "c2291bc05f417329a233d09d769933ccccdc4e1f03e6f2d9a8223464b141728d" - }, - "linux-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-fc82cbad2179/herdr-linux-aarch64", - "sha256": "bce62864bf3cff6046799acd0e8eb2d6eab546f4be9fe0e37a026704a85db3b2" - }, - "macos-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-fc82cbad2179/herdr-macos-x86_64", - "sha256": "7990bdc39fa96322b37a497f85bc1963cb40f9f298d400f8da8fe13dac2adaf6" - }, - "macos-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-fc82cbad2179/herdr-macos-aarch64", - "sha256": "c8bcd63323c6daf671022a6872134c6178c153ddd89c4260fbb9b28fe162c18a" - } - } - }, - "2026-06-02-cce3eacc49d2": { - "base_version": "0.6.6", - "commit": "cce3eacc49d281fd087ebf0d43eea3e08038033f", - "built_at": "2026-06-02T20:59:19Z", - "protocol": 12, - "tag": "preview-2026-06-02-cce3eacc49d2", - "assets": { - "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-02-cce3eacc49d2/herdr-linux-x86_64", - "sha256": "c22aa4b2e5fadd6e04910f92bbc125aa749c6a9a9edd281082f07a839e37cbff" - }, - "linux-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-02-cce3eacc49d2/herdr-linux-aarch64", - "sha256": "26a15f394fd27425ab252424fd090913c1ae5cf644f4f3aac0f06c086bdaabb7" - }, - "macos-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-02-cce3eacc49d2/herdr-macos-x86_64", - "sha256": "c89a97b730e7e3b132a2eca53d49448ec2fb969362e903ce99b5b7b0fbd4d5b1" - }, - "macos-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-02-cce3eacc49d2/herdr-macos-aarch64", - "sha256": "0f6c260d9cc65d01c1a58baca5f3ae8150b91f0a16a2ae223b71e76c9dda444f" - } - } } } } diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 74334fc9..e691bc3c 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -808,6 +808,12 @@ "pane_id": { "type": "string" }, + "restore_error": { + "type": [ + "string", + "null" + ] + }, "revision": { "format": "uint64", "minimum": 0, @@ -2592,110 +2598,6 @@ ], "type": "object" }, - "PaneGraphicsClearParams": { - "properties": { - "layer_id": { - "type": [ - "string", - "null" - ] - }, - "pane_id": { - "type": "string" - } - }, - "required": [ - "pane_id" - ], - "type": "object" - }, - "PaneGraphicsFormat": { - "enum": [ - "png", - "rgb", - "rgba", - "bgra" - ], - "type": "string" - }, - "PaneGraphicsPlacementParams": { - "properties": { - "grid_cols": { - "default": 0, - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "grid_rows": { - "default": 0, - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "viewport_col": { - "default": 0, - "format": "int32", - "type": "integer" - }, - "viewport_row": { - "default": 0, - "format": "int32", - "type": "integer" - } - }, - "type": "object" - }, - "PaneGraphicsSetParams": { - "properties": { - "data_base64": { - "default": "", - "type": "string" - }, - "format": { - "$ref": "#/schemas/request/$defs/PaneGraphicsFormat" - }, - "image_height": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "image_width": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "layer_id": { - "type": [ - "string", - "null" - ] - }, - "pane_id": { - "type": "string" - }, - "placement": { - "$ref": "#/schemas/request/$defs/PaneGraphicsPlacementParams", - "default": { - "grid_cols": 0, - "grid_rows": 0, - "viewport_col": 0, - "viewport_row": 0 - } - }, - "z_index": { - "default": 0, - "format": "int32", - "type": "integer" - } - }, - "required": [ - "pane_id", - "format", - "image_width", - "image_height" - ], - "type": "object" - }, "PaneInputSetParams": { "properties": { "pane_id": { @@ -3007,6 +2909,16 @@ "pane_id": { "type": "string" }, + "resume_argv": { + "description": "Command that resumes this agent's session after a Herdr restart. The\nfirst element must be a plain command name.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "seq": { "format": "uint64", "minimum": 0, @@ -3050,6 +2962,16 @@ "pane_id": { "type": "string" }, + "resume_argv": { + "description": "Command that resumes this agent's session after a Herdr restart. The\nfirst element must be a plain command name.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "seq": { "format": "uint64", "minimum": 0, @@ -4014,6 +3936,18 @@ }, "type": "object" }, + "ServerSshAgentRegisterParams": { + "properties": { + "socket_path": { + "description": "Absolute remote-host agent socket. Registration lasts until this API connection closes.", + "type": "string" + } + }, + "required": [ + "socket_path" + ], + "type": "object" + }, "SnoozeRecordWakeParams": { "properties": { "boot_id": { @@ -4965,6 +4899,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "server.ssh_agent.register", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/ServerSshAgentRegisterParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -5989,6 +5939,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.clear", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneTarget" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -6245,54 +6211,6 @@ ], "type": "object" }, - { - "properties": { - "method": { - "const": "pane.graphics.set", - "type": "string" - }, - "params": { - "$ref": "#/schemas/request/$defs/PaneGraphicsSetParams" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - { - "properties": { - "method": { - "const": "pane.graphics.clear", - "type": "string" - }, - "params": { - "$ref": "#/schemas/request/$defs/PaneGraphicsClearParams" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, - { - "properties": { - "method": { - "const": "pane.graphics.info", - "type": "string" - }, - "params": { - "$ref": "#/schemas/request/$defs/PaneTarget" - } - }, - "required": [ - "method", - "params" - ], - "type": "object" - }, { "properties": { "method": { @@ -6925,6 +6843,15 @@ "agent_status": { "$ref": "#/schemas/success_response/$defs/AgentStatus" }, + "completion_seq": { + "description": "The current idle transition completed work, independently of who has viewed it.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, "cwd": { "type": [ "string", @@ -8271,6 +8198,12 @@ "pane_id": { "type": "string" }, + "restore_error": { + "type": [ + "string", + "null" + ] + }, "revision": { "format": "uint64", "minimum": 0, @@ -10309,108 +10242,6 @@ ], "type": "object" }, - { - "properties": { - "revision": { - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "sequence": { - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "type": { - "const": "pane_graphics_frame_ack", - "type": "string" - } - }, - "required": [ - "type", - "sequence", - "revision" - ], - "type": "object" - }, - { - "properties": { - "cell_height_px": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "cell_width_px": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "file_frame_damage": { - "default": false, - "description": "Accepts damage metadata while still consuming a complete canonical file.", - "type": "boolean" - }, - "file_frame_direct_max_bytes": { - "format": "uint", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "file_frame_directory": { - "type": [ - "string", - "null" - ] - }, - "file_frame_formats": { - "items": { - "type": "string" - }, - "type": "array" - }, - "file_frame_max_bytes": { - "format": "uint", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "file_frame_transport": { - "type": [ - "string", - "null" - ] - }, - "max_layers_per_pane": { - "default": 0, - "format": "uint", - "minimum": 0, - "type": "integer" - }, - "pane_visible": { - "description": "True only when this pane is on the currently rendered terminal surface.", - "type": "boolean" - }, - "pixel_mouse": { - "default": false, - "type": "boolean" - }, - "type": { - "const": "pane_graphics_info", - "type": "string" - } - }, - "required": [ - "type", - "cell_width_px", - "cell_height_px", - "pane_visible" - ], - "type": "object" - }, { "properties": { "explain": true, @@ -10957,6 +10788,11 @@ "live_handoff": { "type": "boolean" }, + "ssh_agent_registration": { + "default": false, + "description": "Supports connection-scoped `server.ssh_agent.register` on the local JSON API.", + "type": "boolean" + }, "surface_interest": { "default": false, "description": "Whether this server supports explicit client-shell surface interest.", diff --git a/docs/next/focus-snooze-fork.md b/docs/next/focus-snooze-fork.md index d2bc5a88..9cfd2245 100644 --- a/docs/next/focus-snooze-fork.md +++ b/docs/next/focus-snooze-fork.md @@ -20,6 +20,10 @@ Focus narrows one client's view to a project or workspace. Snooze hides a projec Snoozing individual agents, tabs, or panes is not supported. +## What's New + +The global menu's "what's new" entry is always available and opens the full merged release history, rendered from the `CHANGELOG.md` embedded in the binary. Released Superherdr sections come first (newest first, `Unreleased` sections omitted), followed by `Inherited from Herdr ` sections that port the upstream release bodies, also newest first. The view needs no network, no filesystem access, and no stored state, so it works the same in a checkout and in an installed build. Closing it sends no request to the server. Startup, product announcements, and update previews behave as before; nothing auto-opens on launch. + ## Known limits - An open Snooze management dialog does not refresh when another client changes a snooze. Actions on stale rows are rejected safely; close and reopen the dialog to refresh it. diff --git a/justfile b/justfile index 48225789..df26df1e 100644 --- a/justfile +++ b/justfile @@ -12,7 +12,12 @@ test: # Run repository maintenance contract tests maintenance-test: - {{python}} -m unittest scripts.test_agent_detection_manifest_check scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_sync_upstream scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty + {{python}} -m unittest scripts.test_agent_detection_manifest_check scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_sync_upstream scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty scripts.test_windows_input + +# Local interactive Windows Terminal input qualification (never runs in normal CI). +[windows] +test-windows-input *args: + pwsh -NoProfile -File scripts/test_windows_input.ps1 -AllowInputInjection -ClearClipboard {{args}} # Run one nextest filter, e.g. `just test-one codex_stale_working` test-one filter: @@ -71,6 +76,22 @@ build: bench-render-scale: cargo test --release --locked --bin herdr render_scale_profile -- --ignored --nocapture --test-threads=1 +# Profile terminal target name resolution at increasing pane counts. +bench-terminal-targets: + cargo test --release --locked --bin herdr terminal_target_lookup_profile -- --ignored --nocapture --test-threads=1 + +# Profile BSP split collection and construction with balanced and skewed trees. +bench-bsp-layout: + cargo test --release --locked --bin herdr bsp_layout_profile -- --ignored --nocapture --test-threads=1 + +# Profile full and retained text, static-image, and unchanged-image updates. +bench-retained-graphics: + cargo test --release --locked --bin herdr render_scale_profile_retained_graphics -- --ignored --nocapture --test-threads=1 + +# Profile first-batch latency and aggregate drain cost for external API bursts. +bench-api-fairness: + cargo test --release --locked --bin herdr external_api_burst_profile -- --ignored --nocapture --test-threads=1 + # ~3-5 minute CPU comparison; downloads stable unless HERDR_PERF_BASELINE_BIN is set bench-release-smoke: cargo build --release --locked diff --git a/nix/package.nix b/nix/package.nix index 611e507c..16968499 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -40,6 +40,7 @@ rustPlatform.buildRustPackage { fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ./..)) ( lib.fileset.unions [ ../assets + ../crates ../distribution/install.ps1 ../docs/next/api/herdr-api.schema.json ../src diff --git a/openspec/changes/archive/2026-10-07-whats-new-full-history/proposal.md b/openspec/changes/archive/2026-10-07-whats-new-full-history/proposal.md new file mode 100644 index 00000000..1ce9cf9f --- /dev/null +++ b/openspec/changes/archive/2026-10-07-whats-new-full-history/proposal.md @@ -0,0 +1,56 @@ +# Change: What's New shows the full merged release history + +> Accepted by jsonmartin (maintainer) in the working session on 2026-10-07, revision 2 (post-critique). Scope: What's New full-history view from the embedded merged changelog; startup behavior untouched. Ships in Superherdr 0.9.3.1 on top of the Herdr 0.9.3 upstream sync (`upstream-sync/v0.9.3`). + +## Why + +### Problem and users + +The global menu's "what's new" entry is fed by a single stored slot, `release-notes.json` in the config directory, written only when the update checker fetches a release. Superherdr's checker is disabled (`src/update.rs` returns immediately), so upgraded installs show whatever the slot froze at — Herdr 0.9.0's notes — while fresh installs, with no stored slot, show no entry at all. The local-changelog loader is reachable only through the fake-preview test helper, not menu loading, and resolves `CHANGELOG.md` relative to the current directory, which only works in a checkout. Installed-build users cannot see what any Superherdr release changed — neither the fork's own Focus/Snooze/Agents entries nor what inherited upstream releases brought. + +This belongs in Superherdr, not upstream: it is about how the fork presents its own combined release history. Herdr's behavior does not change. + +### Prerequisite + +The Herdr 0.9.3 upstream sync (branch `upstream-sync/v0.9.3`, base 0.9.3). Inherited sections cover upstream releases up to the merged base; this change ships as Superherdr 0.9.3.1. + +## What Changes + +The "what's new" view renders Superherdr's `CHANGELOG.md` — Superherdr release sections first, then `Inherited from Herdr` sections — embedded in the binary at build time. The menu entry becomes always available and always opens this history view. + +### Before and after + +| | Before | After | +| --- | --- | --- | +| Menu "what's new" | Entry only when an update or stored notes exist; body frozen at whatever the checker last saved (0.9.0 on upgraded installs); fresh installs have no entry | Always available; always opens the embedded merged changelog, scrollable, Superherdr entries on top | +| Inherited upstream releases | Invisible | `Inherited from Herdr ` subsections under the Superherdr sections | +| Startup | Pending notes are kept available but never auto-open; popups come from the product-announcement system | Unchanged | +| Runtime data source | Config-directory JSON slot | Build-time `include_str!` of `CHANGELOG.md`; no network, no cwd dependence | + +### Decisions + +- One merged document instead of separate theirs/ours tabs: the split is expressed as `###` section headings, the heading level the existing renderer styles; no new view state. +- The menu action opens the history view in all states. Badge and label logic is presentation-only and out of scope; the stored slot's existing roles (update preview plumbing, fake-preview helper) are unchanged. +- `###` for inherited headings keeps `extract_version_section` behavior intact (it terminates on `## [`), with a regression test proving a version extraction excludes a following inherited section. +- The view omits `Unreleased` sections and starts at the newest released Superherdr section. +- `CHANGELOG.md` is Superherdr-owned (`.upstream-sync/ours`); each upstream sync must add the new inherited section as a documented manual porting step (sync report already lists upstream edits to ours files). + +## Alternatives and drawbacks + +- Keep current behavior: installed users stay unable to see release history, and a stale 0.9.0 body misleads. +- Fetch history at runtime from GitHub: adds a network dependency and failure modes for a static document. +- Separate theirs/ours tabs: duplicates view machinery for no extra information. +- Auto-open on startup after an upgrade: rejected; today's behavior deliberately never auto-opens release notes, and this change preserves that. + +## Impact + +- UI: What's New overlay content and menu availability; no other client surface. +- CLI, socket API, persistence: none; the `release-notes.json` format is unchanged. +- Compatibility with Herdr clients and plugins: none touched; presentation-only, client-local. +- Upstream merges: `CHANGELOG.md` stays ours-owned; the sync checklist gains a required porting step. + +## Verification and open questions + +- `just test`: renderer tests over real changelog content (section order, `###` styling, Unreleased omitted); shell test opens the history view with no stored notes, survives empty and stale-note snapshots, closes without endpoint requests, including the mobile activation path; regression test that version extraction excludes a following inherited section. +- Manual: in an installed build outside a checkout, the menu always shows "what's new", and it renders the current Superherdr release on top with inherited 0.9.1–0.9.3 sections below. +- Open questions: none blocking. diff --git a/openspec/changes/archive/2026-10-07-whats-new-full-history/specs/superherdr-release-notes/spec.md b/openspec/changes/archive/2026-10-07-whats-new-full-history/specs/superherdr-release-notes/spec.md new file mode 100644 index 00000000..8a61f0d0 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-whats-new-full-history/specs/superherdr-release-notes/spec.md @@ -0,0 +1,45 @@ +# superherdr-release-notes + +## ADDED Requirements + +### Requirement: The What's New view shows the merged release history + +The client SHALL offer a What's New view that renders the Superherdr `CHANGELOG.md` embedded in the binary, showing released Superherdr sections first (newest first, omitting `Unreleased` sections) followed by `Inherited from Herdr ` subsections, also newest first. The menu entry SHALL be available regardless of update-check or stored-notes state, and the action SHALL open this history view in all states. + +#### Scenario: Installed build shows full history + +- WHEN a user opens "what's new" from the global menu in an installed build outside a repository checkout +- THEN the view shows the newest released Superherdr section first, followed by inherited Herdr sections, scrollable, without filesystem or network access + +#### Scenario: No release ever fetched + +- WHEN the update checker has never saved stored notes, or the stored slot holds an old version +- THEN the menu entry is available and the history view opens with the merged changelog + +#### Scenario: Unreleased entries are not shown + +- WHEN the changelog contains an `Unreleased` section above released sections +- THEN the view omits it and starts at the newest released Superherdr section + +#### Scenario: View survives client snapshots + +- WHEN the history view is open and subsequent client snapshots carry no release notes, stored notes, or updates +- THEN the history view stays open and rendering, and closing it triggers no endpoint request + +### Requirement: Startup and stored-slot behavior is unchanged + +The client SHALL keep pending release notes available without auto-opening them at startup, SHALL keep the product-announcement popup system unchanged, and SHALL NOT auto-open the history view on launch. Stored-slot roles outside the What's New menu action (update preview plumbing, the fake-preview test helper) SHALL be unchanged. + +#### Scenario: Pending notes never auto-open + +- WHEN the app starts with pending release notes in the stored slot +- THEN nothing auto-opens; the notes stay available as today, and the history view is reachable only from the menu + +### Requirement: Inherited sections stay extractable and styleable + +Inherited sections SHALL use `###` headings (`### Inherited from Herdr `), so version extraction that terminates on `## [` headings excludes them and the renderer styles them as sections. + +#### Scenario: Version extraction excludes inherited sections + +- WHEN a Superherdr version section is extracted from the changelog and an inherited section follows it +- THEN the extracted body excludes the inherited section diff --git a/openspec/changes/archive/2026-10-07-whats-new-full-history/tasks.md b/openspec/changes/archive/2026-10-07-whats-new-full-history/tasks.md new file mode 100644 index 00000000..9cf2262d --- /dev/null +++ b/openspec/changes/archive/2026-10-07-whats-new-full-history/tasks.md @@ -0,0 +1,21 @@ +# Tasks + +## 1. Changelog restructure + +- [x] 1.1 Restructure `CHANGELOG.md`: released Superherdr sections first; add `### Inherited from Herdr 0.9.1`, `0.9.2`, and `0.9.3` subsections ported from the upstream release bodies, newest first +- [x] 1.2 Add `CHANGELOG.md` to the Cargo `include` list so packaged builds embed it + +## 2. View wiring + +- [x] 2.1 Embed the changelog (`include_str!`) and expose it to the client shell +- [x] 2.2 Render the What's New view from the embedded changelog, omitting `Unreleased` sections; make the menu entry always available and the action always open the history view +- [x] 2.3 Leave startup, product-announcement, and stored-slot plumbing unchanged + +## 3. Tests and docs + +- [x] 3.1 Renderer tests over real changelog content: released-before-inherited order, `###` styling, `Unreleased` omitted +- [x] 3.2 Shell test: open the history view with no stored notes, apply empty and stale-note snapshots, close, assert no endpoint request; cover the mobile activation path +- [x] 3.3 Regression test: version extraction of a section followed by an inherited section excludes the inherited body +- [x] 3.4 Document the required changelog-graft step in the upstream sync checklist (CONTRIBUTING.md upstream section) +- [x] 3.5 Update `README.md` and `docs/next/` where release notes are described; add the user-facing CHANGELOG entry +- [ ] 3.6 Validate with `bunx --bun @fission-ai/openspec@1.13.0 validate whats-new-full-history --strict`; complete tasks, archive the change in the same PR, and get `just spec-check` green diff --git a/openspec/specs/superherdr-release-notes/spec.md b/openspec/specs/superherdr-release-notes/spec.md new file mode 100644 index 00000000..6ea27f08 --- /dev/null +++ b/openspec/specs/superherdr-release-notes/spec.md @@ -0,0 +1,53 @@ +# superherdr-release-notes Specification + +## Purpose +Describe how Superherdr presents its combined release history: the release-notes view renders the changelog embedded in the binary, released Superherdr sections first, inherited upstream sections below. + +# superherdr-release-notes Specification + +## Purpose +TBD - created by archiving change whats-new-full-history. Update Purpose after archive. + +## Requirements + +### Requirement: The What's New view shows the merged release history + +The client SHALL offer a What's New view that renders the Superherdr `CHANGELOG.md` embedded in the binary, showing released Superherdr sections first (newest first, omitting `Unreleased` sections) followed by `Inherited from Herdr ` subsections, also newest first. The menu entry SHALL be available regardless of update-check or stored-notes state, and the action SHALL open this history view in all states. + +#### Scenario: Installed build shows full history + +- WHEN a user opens "what's new" from the global menu in an installed build outside a repository checkout +- THEN the view shows the newest released Superherdr section first, followed by inherited Herdr sections, scrollable, without filesystem or network access + +#### Scenario: No release ever fetched + +- WHEN the update checker has never saved stored notes, or the stored slot holds an old version +- THEN the menu entry is available and the history view opens with the merged changelog + +#### Scenario: Unreleased entries are not shown + +- WHEN the changelog contains an `Unreleased` section above released sections +- THEN the view omits it and starts at the newest released Superherdr section + +#### Scenario: View survives client snapshots + +- WHEN the history view is open and subsequent client snapshots carry no release notes, stored notes, or updates +- THEN the history view stays open and rendering, and closing it triggers no endpoint request + +### Requirement: Startup and stored-slot behavior is unchanged + +The client SHALL keep pending release notes available without auto-opening them at startup, SHALL keep the product-announcement popup system unchanged, and SHALL NOT auto-open the history view on launch. Stored-slot roles outside the What's New menu action (update preview plumbing, the fake-preview test helper) SHALL be unchanged. + +#### Scenario: Pending notes never auto-open + +- WHEN the app starts with pending release notes in the stored slot +- THEN nothing auto-opens; the notes stay available as today, and the history view is reachable only from the menu + +### Requirement: Inherited sections stay extractable and styleable + +Inherited sections SHALL use `###` headings (`### Inherited from Herdr `), so version extraction that terminates on `## [` headings excludes them and the renderer styles them as sections. + +#### Scenario: Version extraction excludes inherited sections + +- WHEN a Superherdr version section is extracted from the changelog and an inherited section follows it +- THEN the extracted body excludes the inherited section diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py index 6990a215..bfc2c312 100644 --- a/scripts/agent_detection_manifest_check.py +++ b/scripts/agent_detection_manifest_check.py @@ -58,9 +58,9 @@ # publishes the bundled Grok manifest. STAGED_PUBLISHED_MANIFESTS = { "grok": ( - "2026.07.16.2", - "2026.07.16.1", - "1f35b3271a96cf830c64bed78751619bfd8013c277c0d7c0f999b7a433895f28", + "2026.09.18.2", + "2026.09.18.1", + "0f31b111144900b02f303577d27587f72d58d8c505185a682bd7887f822316ee", ), } diff --git a/scripts/generate_libghostty_bindings.sh b/scripts/generate_libghostty_bindings.sh index c87efca7..0868b73e 100644 --- a/scripts/generate_libghostty_bindings.sh +++ b/scripts/generate_libghostty_bindings.sh @@ -8,5 +8,5 @@ cd "$(dirname "$0")/.." --allowlist-function 'ghostty_.*' \ --allowlist-var 'GHOSTTY_.*' \ --with-derive-default \ - --output src/ghostty/bindings.rs \ + --output crates/ghostty-vt/src/bindings.rs \ -- -Ivendor/libghostty-vt/include "$@" diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 new file mode 100644 index 00000000..4fdeb8ef --- /dev/null +++ b/scripts/test_windows_input.ps1 @@ -0,0 +1,477 @@ +#Requires -Version 7.0 +<# Local interactive qualification, deliberately excluded from normal CI. + F12 aborts before the next injected gesture. Use an isolated unlocked desktop. + SendInput has an unavoidable focus race: do not use this while doing other work. +#> +[CmdletBinding()] +param( + [string] $ExePath, + [string] $StablePath, + [string] $PreviewPath, + [ValidateSet('default', 'win32', 'vt')][string] $Profile = 'default', + [ValidateSet('native', 'legacy', 'mok2', 'kitty')][string[]] $Modes = @('native', 'legacy', 'mok2', 'kitty'), + [ValidateSet('stable', 'preview')][string[]] $Channels = @('stable', 'preview'), + [ValidateSet('direct', 'herdr', 'herdr-remote')][string[]] $Paths = @('direct', 'herdr'), + [string[]] $Cases, + [int[]] $Widths = @(80, 119, 120, 121, 132, 160, 240), + [int[]] $Heights = @(24, 50), + [string] $OutputDirectory, + [switch] $AllowInputInjection, + [switch] $ClearClipboard, + [switch] $Manual, + [switch] $Full, + [switch] $MatrixOnly +) +. "$PSScriptRoot/windows_input/Common.ps1" +$reportScript = Join-Path $PSScriptRoot 'windows_input/report.py' +$python = (Get-Command python -ErrorAction Stop).Source +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PWD ".local/windows-input/$([guid]::NewGuid().ToString('N'))" } +if (Test-Path -LiteralPath $OutputDirectory) { throw 'Output directory must be new, to prevent stale evidence or overwrites' } +$root = [IO.Directory]::CreateDirectory($OutputDirectory).FullName +$null = Invoke-GauntletProcess $python @($reportScript, 'matrix', '--output', (Join-Path $root 'matrix.json')) +$matrix = Read-GauntletJson (Join-Path $root 'matrix.json') +if ($MatrixOnly) { Write-Host "Matrix: $root/matrix.json"; exit 0 } +$caseSelectionProvided = $PSBoundParameters.ContainsKey('Cases') +$Cases = @($Cases | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +$knownCases = @($matrix.cases | ForEach-Object id) +if (($caseSelectionProvided -and -not @($Cases).Count) -or @($Cases | Where-Object { $_ -notin $knownCases }).Count) { throw 'Unknown case selection' } +$selectedCases = if (@($Cases).Count) { @($matrix.cases | Where-Object id -in $Cases) } else { @($matrix.cases) } +$customSelection = @($PSBoundParameters.Keys | Where-Object { $_ -in @('Profile', 'Modes', 'Channels', 'Paths', 'Cases', 'Widths', 'Heights', 'Manual') }).Count -gt 0 +$release = -not $Full -and -not $customSelection +if ($release) { + $runSpecs = @($matrix.release_plan) + $releaseCaseIds = @($runSpecs | ForEach-Object { $_.cases } | Sort-Object -Unique) + $selectedCases = @($matrix.cases | Where-Object id -in $releaseCaseIds) + $Channels = @('stable', 'preview'); $Paths = @('herdr'); $Modes = @('legacy', 'mok2', 'kitty') + $Widths = @(80); $Heights = @(24) +} else { + $runSpecs = @(foreach ($channel in $Channels) { foreach ($path in $Paths) { foreach ($mode in $Modes) { + @{ channel = $channel; path = $path; mode = $mode; cases = @($selectedCases | ForEach-Object id) } + } } }) +} +if (-not $IsWindows) { throw 'Real-host qualification requires Windows and an interactive desktop; no tests passed' } +if (-not $AllowInputInjection) { throw 'Read scripts/windows_input/README.md, then explicitly pass -AllowInputInjection on an isolated desktop' } +if (-not [Environment]::UserInteractive) { throw 'Interactive desktop unavailable' } +if ($Widths.Count -eq 0 -or $Heights.Count -eq 0 -or @($Widths | Where-Object { $_ -lt 40 -or $_ -gt 500 }).Count -or @($Heights | Where-Object { $_ -lt 15 -or $_ -gt 150 }).Count) { throw 'Invalid geometry selection' } +Add-Type -Path "$PSScriptRoot/windows_input/Native.cs" +[HerdrInputGauntlet.Desktop]::AssertNotElevated($PID) +[HerdrInputGauntlet.Desktop]::Neutral() +$controllerWindow = [IntPtr]::Zero +if (@($selectedCases | Where-Object id -eq 'mouse-focus-refresh').Count) { + $controllerWindow = [HerdrInputGauntlet.Desktop]::GetForegroundWindow() + if ($controllerWindow -eq [IntPtr]::Zero) { throw 'Controller has no foreground window for focus-cycle qualification' } + [HerdrInputGauntlet.Desktop]::AssertNotElevated([HerdrInputGauntlet.Desktop]::Pid($controllerWindow)) +} +$needsClipboard = $false +foreach ($case in $selectedCases) { + if ($case.kind -in @('paste', 'mouse-interleave', 'clipboard-image', 'clipboard-mixed') -and + @($Modes | Where-Object { $case.expected.ContainsKey($_) }).Count) { $needsClipboard = $true; break } +} +$clipboardFormatsAtStart = if ($needsClipboard -and $ClearClipboard) { + [HerdrInputGauntlet.Desktop]::ClearClipboardForRun() +} else { [HerdrInputGauntlet.Desktop]::CountClipboardFormats() } +if ($needsClipboard -and -not $ClearClipboard -and $clipboardFormatsAtStart -ne 0) { + throw 'Clipboard is not empty; save its contents, then clear it or pass -ClearClipboard to discard it before running' +} +$clipboardFormatsCleared = if ($needsClipboard -and $ClearClipboard) { $clipboardFormatsAtStart } else { 0 } +Write-Host "Clipboard formats before build: $clipboardFormatsAtStart; cleared for test: $clipboardFormatsCleared" +$sourceCommit = $null +$sourceDirty = $null +if (-not $ExePath) { + $repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + $sourceCommit = (& git -C $repo rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { throw 'Could not identify the source checkout commit' } + $sourceDirty = [bool](& git -C $repo status --porcelain) + Write-Host "Building current checkout: $repo" + Push-Location $repo + try { + & cargo build --release --locked + if ($LASTEXITCODE -ne 0) { throw "cargo build failed with exit code $LASTEXITCODE" } + } finally { Pop-Location } + $targetRoot = if ($env:CARGO_TARGET_DIR) { [IO.Path]::GetFullPath($env:CARGO_TARGET_DIR, $repo) } else { Join-Path $repo 'target' } + $builtExe = Join-Path $targetRoot 'release/herdr.exe' + $package = Join-Path $repo '.local/windows-input/cache/Microsoft.Windows.Console.ConPTY.nupkg' + $stage = Join-Path $root 'package' + Write-Host 'Staging the current binary with the pinned ConPTY runtime' + $null = Invoke-GauntletProcess $python @((Join-Path $PSScriptRoot 'package_windows_conpty.py'), 'stage', '--package', $package, '--herdr-exe', $builtExe, '--output-dir', $stage) -Timeout 300 + $ExePath = Join-Path $stage 'herdr.exe' +} +$exe = (Resolve-Path -LiteralPath $ExePath).Path +$conpty = Join-Path ([IO.Path]::GetDirectoryName($exe)) 'conpty/conpty.dll' +if (-not (Test-Path -LiteralPath $conpty -PathType Leaf)) { throw "Selected Herdr binary has no adjacent bundled ConPTY runtime: $exe. Pass -ExePath to a packaged herdr.exe" } +$pwsh = (Get-Process -Id $PID).Path +$exeHash = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash +if ($sourceCommit) { Write-Host "Source: $sourceCommit$(if ($sourceDirty) { ' + working tree changes' })" } +Write-Host "Herdr under test: $exe" +Write-Host "SHA-256: $exeHash" +Write-Host "Pane observer modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')" +Write-Host "Campaign: $(if ($release) { 'release matrix' } else { 'diagnostic' }); channels: $($Channels -join ', '); paths: $($Paths -join ', ')" +$document = @{ schema = 1; run = [IO.Path]::GetFileName($root); started = [DateTime]::UtcNow.ToString('O'); + source_commit = $sourceCommit; source_dirty = $sourceDirty; exe = $exe; exe_sha256 = $exeHash; + powershell = $PSVersionTable.PSVersion.ToString(); os = [Environment]::OSVersion.VersionString; + profile = $Profile; controller_elevated = $false; observations = @(); hosts = @(); errors = @(); cleanup_errors = @(); + clipboard_clear_requested = [bool]$ClearClipboard; clipboard_formats_at_start = $clipboardFormatsAtStart; clipboard_formats_cleared = $clipboardFormatsCleared; + campaign = if ($release) { 'release' } else { 'diagnostic' }; run_specs = $runSpecs; + widths = $Widths; heights = $Heights; modes = $Modes; channels = $Channels; paths = $Paths; + cases = @($selectedCases | ForEach-Object id); note = 'Desktop input evidence; native qualification still required' } +$rawReport = Join-Path $root 'observations.json' +$artifactReport = Join-Path $root 'report.json' + +function Save-Report { Write-GauntletJson $rawReport $document } +function Resolve-Terminal($Name, $Override) { + if ($Override) { return @{ path = (Resolve-Path -LiteralPath $Override).Path; app_user_model_id = $null } } + $packageName = if ($Name -eq 'stable') { 'Microsoft.WindowsTerminal' } else { 'Microsoft.WindowsTerminalPreview' } + $package = @(Get-AppxPackage -Name $packageName -ErrorAction SilentlyContinue | Sort-Object Version -Descending) + if ($package.Count -eq 0) { return $null } + $path = Join-Path $package[0].InstallLocation 'WindowsTerminal.exe' + if (-not (Test-Path -LiteralPath $path)) { return $null } + return @{ path = $path; app_user_model_id = "$($package[0].PackageFamilyName)!App" } +} +function Observer-Request($Plan, $Action, $Value = $null) { + $id = [guid]::NewGuid().ToString('N') + Write-GauntletJson (Join-Path $Plan.work 'request.json') @{ id = $id; nonce = $Plan.nonce; action = $Action; value = $Value } + $reply = Wait-GauntletJson (Join-Path $Plan.work 'reply.json') $root { param($v) $v.id -eq $id -and $v.nonce -eq $Plan.nonce -and $v.action -eq $Action } + if ($reply.error) { throw "Observer failed: $($reply.error)" } + return $reply +} +function Outer-State($Plan) { + $after = if ($Plan.ContainsKey('outer_sequence')) { $Plan.outer_sequence } else { 0 } + $state = Wait-GauntletJson (Join-Path $Plan.work 'outer.json') $root { param($v) $v.nonce -eq $Plan.nonce -and $v.sequence -gt $after } + $Plan.outer_sequence = $state.sequence + return $state +} +function Get-GauntletClientTrace($Plan) { + # The client writes its mapper trace into the named session's data directory. + # The client log grows independently of the transport log, so this keeps its + # own cursor rather than reusing the transport line count. + $log = Join-Path $Plan.config_home "herdr/sessions/$($Plan.session)/herdr-client.log" + if (-not (Test-Path -LiteralPath $log)) { return $null } + $lines = @(Get-Content -LiteralPath $log | Where-Object { $_ -like '*windows input trace: input batch*' }) + $since = if ($Plan.ContainsKey('client_trace_lines')) { [int]$Plan.client_trace_lines } else { 0 } + $Plan.client_trace_lines = $lines.Count + if ($lines.Count -le $since) { return @() } + return @($lines | Select-Object -Skip $since) +} +function Set-ObservedGeometry($Plan, $Window, $WindowPid, $Width, $Height) { + # Correct against actual console cell measurements, not a claimed pixel size. + for ($attempt = 0; $attempt -lt 8; $attempt++) { + [HerdrInputGauntlet.Desktop]::Guard($Window, $Plan.nonce, $WindowPid) + $outer = Outer-State $Plan + $w = [int]$outer.geometry[0]; $h = [int]$outer.geometry[1] + if ($w -eq $Width -and $h -eq $Height) { return $outer } + [HerdrInputGauntlet.Desktop]::Resize($Window, $Plan.nonce, $WindowPid, ($Width - $w) * 8, ($Height - $h) * 16) + Start-Sleep -Milliseconds 350 + Update-GauntletLease $root + } + return $null +} +function New-Observation($HostName, $Plan, $Width, $Height, $Case) { + return @{ host = $HostName; path = $Plan.path; mode = $Plan.mode; width = $Width; height = $Height; case = $Case.id; + nonce = $Plan.nonce; status = 'inconclusive'; ready = $false; focus_verified = $false; complete = $false } +} + +try { + [HerdrInputGauntlet.Desktop]::StartEmergencyStop() + $terminals = @{ stable = (Resolve-Terminal 'stable' $StablePath); preview = (Resolve-Terminal 'preview' $PreviewPath) } + foreach ($channel in $Channels) { + $terminal = $terminals[$channel] + if ($terminal) { Write-Host "Windows Terminal $channel`: $($terminal.path) ($((Get-Item $terminal.path).VersionInfo.FileVersion))" } + else { Write-Host "Windows Terminal $channel`: not installed (channel will be not_run)" } + } + $launcherIdentities = @{}; $installationIdentities = @{} + foreach ($channel in $Channels) { + if ($terminals[$channel]) { + $launcherIdentities[$channel] = [HerdrInputGauntlet.Desktop]::FileIdentity($terminals[$channel].path) + $installationIdentities[$channel] = [HerdrInputGauntlet.Desktop]::FileIdentity([IO.Path]::GetDirectoryName($terminals[$channel].path)) + } + } + if ($Channels -contains 'stable' -and $Channels -contains 'preview' -and $terminals.stable -and $terminals.preview -and ($launcherIdentities.stable -eq $launcherIdentities.preview -or $installationIdentities.stable -eq $installationIdentities.preview)) { + throw 'Stable and Preview resolve to the same executable/installation; refusing duplicate channel evidence' + } + foreach ($hostName in $Channels) { + $terminal = $terminals[$hostName] + if (-not $terminal) { + $document.hosts += @{ channel = $hostName; status = 'not_run'; reason = 'Terminal installation not found; supply explicit path' } + Save-Report + continue + } + $hostRecord = @{ channel = $hostName; launcher = $terminal.path; launcher_version = (Get-Item $terminal.path).VersionInfo.FileVersion; + launcher_identity = $launcherIdentities[$hostName]; installation_identity = $installationIdentities[$hostName]; runs = @() } + $document.hosts += $hostRecord + foreach ($spec in @($runSpecs | Where-Object channel -eq $hostName)) { + $path = [string]$spec.path; $mode = [string]$spec.mode + $runCases = @($selectedCases | Where-Object id -in $spec.cases) + $nonce = 'herdr-gauntlet-' + [guid]::NewGuid().ToString('N') + $work = [IO.Directory]::CreateDirectory((Join-Path $root $nonce)).FullName + $configHome = [IO.Directory]::CreateDirectory((Join-Path $work 'config')).FullName + $config = Join-Path $configHome 'test.toml' + $shellToml = $pwsh | ConvertTo-Json -Compress + [IO.File]::WriteAllText($config, "onboarding = false`n[terminal]`ndefault_shell = $shellToml`n[ui]`nmouse_capture = true`n") + $plan = @{ root = $root; work = $work; nonce = $nonce; session = $nonce; config = $config; config_home = $configHome; + path = $path; mode = $mode; profile = $Profile; exe = $exe; pwsh = $pwsh; input_trace = (Join-Path $work 'input-transport.log') } + $planPath = Join-Path $work 'plan.json' + Write-GauntletJson $planPath $plan + $window = [IntPtr]::Zero; $windowPid = 0; $server = $null; $launcher = $null; $clipboardSequence = $null; $injectionAuthorized = $false + $cursorPosition = $null; $mouseReporting = $false + Update-GauntletLease $root + try { + if ($path -in @('herdr', 'herdr-remote')) { + $server = New-GauntletProcess $exe @('--session', $nonce, 'server') $plan -Capture + # Drain pipes asynchronously; server output must never block readiness. + $serverOut = $server.StandardOutput.ReadToEndAsync(); $serverErr = $server.StandardError.ReadToEndAsync() + $deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + Update-GauntletLease $root + try { $status = Invoke-GauntletProcess $exe @('--session', $nonce, 'status', 'server') $plan -Timeout 5 } catch { $status = '' } + if ($status -match 'status: running') { break } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + if ($status -notmatch 'status: running') { throw 'Owned server did not become ready' } + $created = (Invoke-GauntletProcess $exe @('--session', $nonce, 'workspace', 'create', '--cwd', $work, '--focus') $plan) | ConvertFrom-Json + $pane = $created.result.root_pane.pane_id + if (-not $pane) { throw 'Missing owned probe pane identity' } + $command = '& ' + (Quote-GauntletPS $pwsh) + ' -NoProfile -File ' + (Quote-GauntletPS "$PSScriptRoot/windows_input/Probe.ps1") + ' -PlanPath ' + (Quote-GauntletPS $planPath) + $null = Invoke-GauntletProcess $exe @('--session', $nonce, 'pane', 'run', $pane, $command) $plan + } + # Always request a new window. Bootstrap independently clears inherited identity. + $terminalArguments = @('-w', 'new', 'new-tab', '--title', $nonce, '--suppressApplicationTitle', '--', $pwsh, '-NoProfile', '-File', "$PSScriptRoot/windows_input/Bootstrap.ps1", '-PlanPath', $planPath) + if ($terminal.app_user_model_id) { + $launcher = Get-Process -Id ([HerdrInputGauntlet.Desktop]::ActivateApplication($terminal.app_user_model_id, $terminalArguments)) -ErrorAction SilentlyContinue + } else { + $launcher = New-GauntletProcess $terminal.path $terminalArguments $plan + } + $deadline = [DateTime]::UtcNow.AddSeconds(25) + do { + Update-GauntletLease $root + $window = [HerdrInputGauntlet.Desktop]::Find($nonce) + if ($window -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ($window -eq [IntPtr]::Zero) { throw 'No unique owned Terminal window appeared' } + $windowPid = [HerdrInputGauntlet.Desktop]::Pid($window) + $windowProcess = Get-Process -Id $windowPid + if ($windowProcess.ProcessName -ne 'WindowsTerminal') { throw 'Nonce window is not Windows Terminal; refusing injection' } + [HerdrInputGauntlet.Desktop]::AssertNotElevated($windowPid) + $imageIdentity = [HerdrInputGauntlet.Desktop]::FileIdentity($windowProcess.Path) + $installationIdentity = [HerdrInputGauntlet.Desktop]::FileIdentity([IO.Path]::GetDirectoryName($windowProcess.Path)) + $processIdentity = "$windowPid/$($windowProcess.StartTime.ToUniversalTime().Ticks)" + foreach ($otherHost in $document.hosts) { + if ($otherHost.channel -ne $hostName -and $otherHost.ContainsKey('runs')) { + foreach ($other in $otherHost.runs) { + if ($other.image_identity -eq $imageIdentity -or $other.installation_identity -eq $installationIdentity -or $other.process_identity -eq $processIdentity) { + throw 'Stable and Preview activated the same Terminal installation/process; refusing duplicate evidence' + } + } + } + } + $runRecord = @{ nonce = $nonce; path = $path; mode = $mode; hwnd = $window.ToInt64(); pid = $windowPid; + terminal_path = $windowProcess.Path; terminal_version = $windowProcess.MainModule.FileVersionInfo.FileVersion; + elevated = $false; image_identity = $imageIdentity; installation_identity = $installationIdentity; process_identity = $processIdentity; + layout = [HerdrInputGauntlet.Desktop]::Layout($window) } + $hostRecord.runs += $runRecord + $ready = Wait-GauntletJson (Join-Path $work 'ready.json') $root { param($v) $v.nonce -eq $nonce -and $v.mode -eq $mode } + [HerdrInputGauntlet.Desktop]::Focus($window, $nonce, $windowPid) + $injectionAuthorized = $true + Start-Sleep -Milliseconds 500 + # Full case catalogue once; boundary geometries run discriminating sentinels. + $geometries = @(@{ width = 120; height = 30; full = $true }) + foreach ($height in $Heights) { foreach ($width in $Widths) { $geometries += @{ width = $width; height = $height; full = $false } } } + # Return narrow after wide to exercise repeated reflow/recovery. + $geometries += @{ width = 80; height = 30; full = $false } + $phase = 0 + foreach ($geometry in $geometries) { + $phase++ + $selected = if ($geometry.full) { $runCases } else { @($runCases | Where-Object { $_.id -in @('letter-a', 'shift-enter', 'paste-lf', 'mouse-focus-refresh') }) } + $outer = Set-ObservedGeometry $plan $window $windowPid $geometry.width $geometry.height + foreach ($case in $selected) { + $row = New-Observation $hostName $plan $geometry.width $geometry.height $case + $row.phase = $phase + $document.observations += $row + if ($case.kind -eq 'qualification' -or -not $case.expected.ContainsKey($mode)) { $row.status = 'not_run'; $row.reason = 'Requires separate qualification: ' + $case.id; continue } + if ($case.kind -eq 'manual' -and -not $Manual) { $row.status = 'not_run'; $row.reason = 'Operator-assisted case; rerun with -Manual and declared layout'; continue } + $layoutChords = $null + if ($case.kind -eq 'layout-key') { + try { + $layoutChords = @([HerdrInputGauntlet.Desktop]::DeadKeyChord($window, [char]$case.dead), [int[]]@([int]$case.base_vk)) + $row.input_layout = [HerdrInputGauntlet.Desktop]::Layout($window) + } catch { + $row.status = 'not_run' + $row.reason = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message } + continue + } + } + if ($null -eq $outer) { $row.status = 'not_run'; $row.reason = 'Requested cell geometry not reached; monitor/font/window constraints'; continue } + $fresh = Outer-State $plan + if ($fresh.geometry[0] -ne $geometry.width -or $fresh.geometry[1] -ne $geometry.height) { + $fresh = Set-ObservedGeometry $plan $window $windowPid $geometry.width $geometry.height + if ($null -eq $fresh) { $row.status = 'inconclusive'; $row.reason = 'Geometry changed and could not be restored'; continue } + } + $row.outer_geometry = $fresh.geometry + $row.outer_sequence = $fresh.sequence + $row.negotiation_hex = $ready.negotiation_hex + if ($mode -eq 'kitty' -and -not $ready.kitty_acknowledged) { $row.status = 'inconclusive'; $row.reason = 'Kitty disambiguation query not acknowledged; host support is not established'; continue } + if ($case.kind -in @('paste', 'mouse-interleave', 'clipboard-image', 'clipboard-mixed') -and [HerdrInputGauntlet.Desktop]::CountClipboardFormats() -ne 0) { + $row.status = 'not_run'; $row.reason = 'Clipboard is not empty; refusing to replace user data'; continue + } + if ($case.kind -in @('mouse-interleave', 'mouse-focus-refresh')) { $null = Observer-Request $plan 'mouse-on'; $mouseReporting = $true } + $traceLineCount = if ($path -in @('herdr', 'herdr-remote') -and (Test-Path -LiteralPath $plan.input_trace)) { @(Get-Content -LiteralPath $plan.input_trace).Count } else { 0 } + $begin = Observer-Request $plan 'begin' + $row.ready = $true; $row.pane_geometry = $begin.geometry + [HerdrInputGauntlet.Desktop]::Guard($window, $nonce, $windowPid) + $row.focus_verified = $true + if ($case.kind -eq 'key') { + $row.scans = @([HerdrInputGauntlet.Desktop]::Scans($window, [int[]]$case.chords[0])) + foreach ($chord in $case.chords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) } + } elseif ($case.kind -eq 'paste') { + $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboard($window, $case.text) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86)) + } elseif ($case.kind -in @('clipboard-image', 'clipboard-mixed')) { + $text = if ($case.kind -eq 'clipboard-mixed') { [string]$case.text } else { $null } + $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboardImage($window, $text) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86)) + } elseif ($case.kind -eq 'mouse-interleave') { + $cursorPosition = [HerdrInputGauntlet.Desktop]::Cursor() + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(65)) + [HerdrInputGauntlet.Desktop]::MouseMoveInside($window, $nonce, $windowPid, -60) + Start-Sleep -Milliseconds 150 + $clipboardSequence = [HerdrInputGauntlet.Desktop]::SetEmptyClipboard($window, $case.text) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 86)) + Start-Sleep -Milliseconds 150 + [HerdrInputGauntlet.Desktop]::MouseMoveInside($window, $nonce, $windowPid, 60) + Start-Sleep -Milliseconds 150 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(66)) + } elseif ($case.kind -eq 'mouse-focus-refresh') { + $cursorPosition = [HerdrInputGauntlet.Desktop]::Cursor() + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(65)) + [HerdrInputGauntlet.Desktop]::MouseClickWheelInside($window, $nonce, $windowPid, -60) + Start-Sleep -Milliseconds 200 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(66)) + [HerdrInputGauntlet.Desktop]::FocusAwayAndBack($controllerWindow, $window, $nonce, $windowPid) + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(67)) + [HerdrInputGauntlet.Desktop]::MouseClickWheelInside($window, $nonce, $windowPid, 60) + Start-Sleep -Milliseconds 200 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(68)) + } elseif ($case.kind -eq 'mode-transitions') { + foreach ($step in @( + @{ mode = 'legacy'; chords = @([int[]]@(65), [int[]]@(16, 13)) }, + @{ mode = 'mok2'; chords = @([int[]]@(66), [int[]]@(16, 13)) }, + @{ mode = 'kitty'; chords = @([int[]]@(67), [int[]]@(16, 13)) }, + @{ mode = 'mok2'; chords = @([int[]]@(68), [int[]]@(16, 13)) }, + @{ mode = 'legacy'; chords = @([int[]]@(69), [int[]]@(16, 13), [int[]]@(70)) } + )) { + if ($step.mode) { $null = Observer-Request $plan 'set-mode' $step.mode; Start-Sleep -Milliseconds 200 } + foreach ($chord in $step.chords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) } + } + } elseif ($case.kind -eq 'layout-key') { + foreach ($chord in $layoutChords) { $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]$chord) } + } else { + Write-Host "$($case.id): $($case.prompt) Return to this controller and press Enter after the gesture." + # Manual waits are bounded by the independent bootstrap lease watchdog. + $null = Read-Host + $row.operator_layout = [HerdrInputGauntlet.Desktop]::Layout($window) + } + Start-Sleep -Milliseconds 500 + if ($case.kind -ne 'manual') { [HerdrInputGauntlet.Desktop]::Guard($window, $nonce, $windowPid) } + $end = Observer-Request $plan 'end' + if ($case.kind -eq 'mode-transitions') { $null = Observer-Request $plan 'set-mode' $mode } + $row.capture_id = $end.id + $row.hex = $end.hex; $row.records = $end.records; $row.error = $end.error; $row.complete = $end.quiet_reached + if ($case.kind -eq 'clipboard-image' -and $path -eq 'herdr-remote') { + $capture = [Convert]::FromHexString([string]$end.hex) + if ($capture.Length -ge 12) { + $stagedPath = [Text.Encoding]::UTF8.GetString($capture, 6, $capture.Length - 12) + if (Test-Path -LiteralPath $stagedPath -PathType Leaf) { + $row.staged_image_sha256 = (Get-FileHash -LiteralPath $stagedPath -Algorithm SHA256).Hash + } + } + } + $row.final_outer_geometry = (Outer-State $plan).geometry + $row.status = 'observed'; $row.final_pane_geometry = $end.geometry + if ($path -in @('herdr', 'herdr-remote') -and (Test-Path -LiteralPath $plan.input_trace)) { + $traceLines = @(Get-Content -LiteralPath $plan.input_trace) + $trace = $traceLines -join "`n" + $captureTrace = ($traceLines | Select-Object -Skip $traceLineCount) -join "`n" + $row.input_reader = if ($trace.Contains('reader=windows-console')) { 'windows-console' } elseif ($trace.Contains('reader=crossterm')) { 'crossterm' } else { 'unknown' } + if ($captureTrace.Contains('transport=win32-serialized')) { $row.input_transport = 'win32-serialized' } + } + if ($path -in @('herdr', 'herdr-remote')) { + # The mapper's own client-event view disambiguates a paste the + # terminal issued from a remote-image-bridge reaction (#4314). + $clientEvents = Get-GauntletClientTrace $plan + if ($null -ne $clientEvents) { $row.client_events = $clientEvents } + } + if ($null -ne $clipboardSequence) { + if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($window, $clipboardSequence)) { throw 'Could not clear test-owned clipboard' } + $clipboardSequence = $null + } + if ($mouseReporting) { $null = Observer-Request $plan 'mouse-off'; $mouseReporting = $false } + if ($null -ne $cursorPosition) { [HerdrInputGauntlet.Desktop]::RestoreCursor($cursorPosition); $cursorPosition = $null } + if ($case.kind -eq 'manual') { [HerdrInputGauntlet.Desktop]::Focus($window, $nonce, $windowPid) } + Save-Report + Update-GauntletLease $root + } + } + } catch { + $document.errors += "$nonce : $($_.Exception.Message)" + Save-Report + # Abort the campaign on lost focus, readiness failure or partial injection. + throw + } finally { + if ($mouseReporting) { + try { $null = Observer-Request $plan 'mouse-off'; $mouseReporting = $false } catch { $document.cleanup_errors += $_.Exception.Message } + } + if ($null -ne $cursorPosition -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) { + [HerdrInputGauntlet.Desktop]::RestoreCursor($cursorPosition); $cursorPosition = $null + } + if ($null -ne $clipboardSequence -and -not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($window, $clipboardSequence)) { $document.cleanup_errors += 'Could not clear test-owned clipboard' } + [IO.File]::WriteAllText((Join-Path $work 'probe-stop'), '') + if (Test-Path (Join-Path $work 'ready.json')) { + try { + $probeExit = Wait-GauntletJson (Join-Path $work 'probe-exit.json') $root { param($v) $v.nonce -eq $nonce } -Timeout 5 + $document.cleanup_errors += @($probeExit.cleanup_errors) + } catch { $document.cleanup_errors += $_.Exception.Message } + } + # Normal detach only while still owning foreground focus; never type into another window. + if ($injectionAuthorized -and $path -in @('herdr', 'herdr-remote') -and [HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $window) { + try { + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(17, 66)) + Start-Sleep -Milliseconds 100 + $null = [HerdrInputGauntlet.Desktop]::Chord($window, $nonce, $windowPid, [int[]]@(81)) + } catch { $document.cleanup_errors += $_.Exception.Message } + } + if ($null -ne $server -and -not $server.HasExited) { + try { $null = Invoke-GauntletProcess $exe @('session', 'stop', $nonce) $plan } + catch { if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += $_.Exception.Message } } + } + [IO.File]::WriteAllText((Join-Path $work 'stop'), '') + if ($window -ne [IntPtr]::Zero) { + try { + $exitRecord = Wait-GauntletJson (Join-Path $work 'bootstrap-exit.json') $root { param($v) $v.nonce -eq $nonce } -Timeout 45 + $document.cleanup_errors += @($exitRecord.cleanup_errors) + if ($exitRecord.before[2] -ne $exitRecord.after[2]) { $document.cleanup_errors += "$nonce console input mode not restored" } + } catch { $document.cleanup_errors += $_.Exception.Message } + $windowDeadline = [DateTime]::UtcNow.AddSeconds(5) + while ([HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [DateTime]::UtcNow -lt $windowDeadline) { Start-Sleep -Milliseconds 100 } + if ([HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid)) { $document.cleanup_errors += "$nonce Terminal window remained open; close it manually" } + } + if ($null -ne $server) { + if (-not $server.WaitForExit(5000)) { + $server.Kill($true) + $document.cleanup_errors += "$nonce server required forced cleanup" + if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += "$nonce server remained active after forced cleanup" } + } + try { $null = Invoke-GauntletProcess $exe @('session', 'delete', $nonce) $plan } catch { $document.cleanup_errors += $_.Exception.Message } + $server.Dispose() + } + if ($null -ne $launcher) { $launcher.Dispose() } + Save-Report + } + } + } +} catch { + if ($document.errors.Count -eq 0) { $document.errors += $_.Exception.Message } +} finally { [HerdrInputGauntlet.Desktop]::StopEmergencyStop(); Save-Report } +& $python $reportScript report --input $rawReport --output $artifactReport +$code = $LASTEXITCODE +Write-Host "Evidence and report: $root" +exit $code diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py new file mode 100644 index 00000000..09b60e2c --- /dev/null +++ b/scripts/test_windows_input.py @@ -0,0 +1,390 @@ +"""Portable tests of the gauntlet's oracle, not Windows input qualification.""" +import copy +import unittest +from scripts.windows_input.report import classification_of_client_events, catalogue, channel_identity_errors, herdr_protocol_label, known_host_gap, qualification_matrix, summarize, verdict + + +class WindowsInputGauntletTests(unittest.TestCase): + def setUp(self): + self.matrix = catalogue() + self.cases = {case["id"]: case for case in self.matrix["cases"]} + self.evidence = dict(ready=True, focus_verified=True, complete=True, width=121, height=24, + outer_geometry=[121, 24, 0x298], pane_geometry=[100, 20, 0x200], + final_outer_geometry=[121, 24, 0x298], final_pane_geometry=[100, 20, 0x200]) + + def test_catalogue_has_unique_cases_and_geometry_boundaries(self): + self.assertEqual(len(self.cases), len(self.matrix["cases"])) + self.assertEqual(self.matrix["widths"], [80, 119, 120, 121, 132, 160, 240]) + self.assertEqual(self.matrix["heights"], [24, 50]) + for case in self.cases.values(): + self.assertTrue(case["id"]) + self.assertLessEqual(set(case["expected"]), set(self.matrix["modes"])) + for expected in case["expected"].values(): + for value in expected.get("hex", []): + self.assertTrue(bytes.fromhex(value)) + self.assertEqual(self.cases["dead-acute"]["kind"], "layout-key") + self.assertEqual(self.cases["altgr-euro"]["kind"], "manual") + for spec in self.matrix["release_plan"]: + for case_id in spec["cases"]: + self.assertIn(spec["mode"], self.cases[case_id]["expected"]) + self.assertNotIn(self.cases[case_id]["kind"], {"manual", "qualification"}) + self.assertFalse({"page-up", "page-down"} & {case_id for spec in self.matrix["release_plan"] for case_id in spec["cases"]}) + + def test_default_catalogue_does_not_inject_terminal_host_actions(self): + for case_id in ["ctrl-v", "alt-enter", "ctrl-shift-up", "ctrl-shift-down", "ctrl-shift-home", "ctrl-shift-end"]: + self.assertEqual(self.cases[case_id]["kind"], "qualification", case_id) + + def test_kitty_function_keys_accept_native_and_legacy_encodings(self): + self.assertEqual(self.cases["shift-tab"]["expected"]["kitty"]["hex"], ["1b5b5a", "1b5b393b3275"]) + self.assertEqual(self.cases["up"]["expected"]["kitty"]["hex"], ["1b5b41", "1b5b353734313975"]) + + def test_shift_enter_cannot_pass_with_plain_enter_or_trailing_duplicates(self): + case = self.cases["shift-enter"] + correct = "1b5b32373b323b31337e" + for value, status in [("0d", "fail"), (correct, "pass"), (correct + "0d", "fail")]: + self.assertEqual(verdict(case, "mok2", {**self.evidence, "hex": value})[0], status) + self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": "0d"})[0], "unsupported") + + def test_focus_readiness_and_actual_geometry_are_required(self): + case = self.cases["letter-a"] + good = {**self.evidence, "hex": "61"} + for field in ["ready", "focus_verified", "complete", "pane_geometry", "outer_geometry", "final_pane_geometry", "final_outer_geometry"]: + value = copy.deepcopy(good) + value.pop(field) + self.assertEqual(verdict(case, "legacy", value)[0], "inconclusive", field) + self.assertEqual(verdict(case, "legacy", {**good, "outer_geometry": [120, 24]})[0], "inconclusive") + for malformed in [1, "121,24", {}, [121], [121, "24"], [True, 24]]: + self.assertEqual(verdict(case, "legacy", {**good, "pane_geometry": malformed})[0], "inconclusive") + + def test_native_modifiers_release_and_repeat_are_not_discarded(self): + # type, down, repeat, vk, scan, Unicode, control-state + self.evidence["scans"] = [42, 28] + records = [[1, 1, 1, 13, 28, 13, 16], [1, 0, 1, 13, 28, 13, 16]] + case = self.cases["shift-enter"] + self.assertEqual(verdict(case, "native", {**self.evidence, "records": records})[0], "pass") + for index, value in [(6, 0), (2, 2), (3, 10), (4, 0), (5, 122)]: + bad = copy.deepcopy(records) + bad[0][index] = value + self.assertEqual(verdict(case, "native", {**self.evidence, "records": bad})[0], "fail") + self.assertEqual(verdict(case, "native", {**self.evidence, "records": records[:1]})[0], "fail") + extra = [[1, 1, 1, 16, 42, 0, 16]] + records + self.assertEqual(verdict(case, "native", {**self.evidence, "records": extra})[0], "fail") + balanced = extra + [[1, 0, 1, 16, 42, 0, 0]] + self.assertEqual(verdict(case, "native", {**self.evidence, "records": balanced})[0], "pass") + + def test_herdr_native_page_keys_must_be_consumed_before_the_pane(self): + case = self.cases["page-up"] + evidence = {**self.evidence, "path": "herdr", "scans": [73]} + records = [[1, 1, 1, 33, 73, 0, 0], [1, 0, 1, 33, 73, 0, 0]] + self.assertEqual(verdict(case, "native", {**evidence, "records": []})[0], "inconclusive") + self.assertEqual(verdict(case, "native", {**evidence, "records": [[4, 0, 0, 0, 0, 0, 0]]})[0], "inconclusive") + self.assertEqual(verdict(case, "native", {**evidence, "records": records})[0], "fail") + self.assertEqual(verdict(case, "native", {**evidence, "records": [None]})[0], "inconclusive") + self.assertEqual(verdict(case, "native", {**evidence, "path": "direct", "records": records})[0], "pass") + for mode in ("legacy", "mok2", "kitty"): + self.assertEqual(verdict(case, mode, {**evidence, "hex": ""})[0], "inconclusive") + self.assertEqual(verdict(case, mode, {**evidence, "hex": "1b5b357e"})[0], "fail") + + def test_paste_requires_one_envelope_and_complete_unicode_payload(self): + case = self.cases["paste-unicode"] + payload = case["text"].replace("\n", "\r\n").encode() + framed = b"\x1b[200~" + payload + b"\x1b[201~" + for data, expected in [(framed, "pass"), (payload, "fail"), (framed * 2, "fail"), + (framed[:-1], "fail"), (framed + b"\r", "fail"), + (b"\x1b[200~\xff\x1b[201~", "fail")]: + self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": data.hex()})[0], expected) + + def test_remote_clipboard_image_requires_empty_host_paste_and_staged_png_path(self): + case = self.cases["clipboard-image"] + empty_paste = b"\x1b[200~\x1b[201~" + staged = b"\x1b[200~C:\\Temp\\herdr-clipboard-images-user\\image.png\x1b[201~" + self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "direct", "hex": empty_paste.hex()})[0], "pass") + remote = {**self.evidence, "path": "herdr-remote", "hex": staged.hex(), + "paste_origin": "empty-paste", + "staged_image_sha256": case["expected"]["legacy"]["sha256"]} + self.assertEqual(verdict(case, "legacy", remote)[0], "pass") + self.assertEqual(verdict(case, "legacy", {**remote, "staged_image_sha256": "0" * 64})[0], "fail") + self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "herdr-remote", "hex": empty_paste.hex()})[0], "fail") + self.assertEqual(verdict(case, "legacy", {**self.evidence, "path": "herdr", "hex": empty_paste.hex()})[0], "not_run") + + def test_remote_clipboard_image_rejects_a_paste_the_terminal_issued(self): + # A staged PNG that came from a terminal-issued text paste proves nothing + # about the empty-paste bridge (#4314), and a staged PNG with no mapper + # evidence at all must not qualify the bridge either. + case = self.cases["clipboard-image"] + staged = b"\x1b[200~C:\\Temp\\herdr-clipboard-images-user\\image.png\x1b[201~" + remote = {**self.evidence, "path": "herdr-remote", "hex": staged.hex(), + "staged_image_sha256": case["expected"]["legacy"]["sha256"]} + self.assertEqual(verdict(case, "legacy", {**remote, "paste_origin": "terminal-paste"})[0], "fail") + for weak in ("none", "key-event", None): + self.assertEqual(verdict(case, "legacy", {**remote, "paste_origin": weak})[0], "inconclusive", weak) + self.assertEqual(verdict(case, "legacy", {**remote, "paste_origin": "empty-paste"})[0], "pass") + direct = {**self.evidence, "path": "direct", "hex": b"\x1b[200~\x1b[201~".hex()} + self.assertEqual(verdict(case, "legacy", {**direct, "paste_origin": "terminal-paste"})[0], "fail") + self.assertEqual(verdict(case, "legacy", {**direct, "paste_origin": "empty-paste"})[0], "pass") + + def test_client_event_trace_classifies_paste_origin(self): + self.assertEqual(classification_of_client_events(None), None) + self.assertEqual(classification_of_client_events([]), "none") + self.assertEqual( + classification_of_client_events(['mapped_event_groups=[Paste { text: "" }]']), + "empty-paste") + self.assertEqual( + classification_of_client_events(['mapped_event_groups=[Paste { text: "hello" }]']), + "terminal-paste") + self.assertEqual( + classification_of_client_events( + ["mapped_event_groups=[Key { code: Char('v'), modifiers: 0, kind: Press }]"]), + "key-event") + # A release alone is not a consumed press. + self.assertEqual( + classification_of_client_events( + ["mapped_event_groups=[Key { code: Char('v'), modifiers: 0, kind: Release }]"]), + "none") + self.assertEqual(classification_of_client_events(["not a trace line", 7]), None) + + def test_mouse_interleave_requires_ordered_motion_and_paste(self): + case = self.cases["mouse-interleave"] + motion = b"\x1b[<35;10;5M" + paste = b"\x1b[200~mouse\r\npaste\x1b[201~" + good = b"a" + motion + paste + motion + b"b" + self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": good.hex()})[0], "pass") + for bad in (good.replace(motion, b"", 1), good + b"b", b"a" + motion + motion + paste + b"b"): + self.assertEqual(verdict(case, "kitty", {**self.evidence, "hex": bad.hex()})[0], "fail") + + def test_runtime_mode_transition_has_one_exact_order(self): + case = self.cases["mode-transitions"] + for mode in ("legacy", "mok2", "kitty"): + expected = case["expected"][mode]["hex"][0] + self.assertEqual(verdict(case, mode, {**self.evidence, "hex": expected})[0], "pass") + self.assertEqual(verdict(case, mode, {**self.evidence, "hex": expected + "0d"})[0], "fail") + direct = {**self.evidence, "path": "direct"} + for observed in ("a\rb\rc\rd\re\rf", "a\rb\rc\x1b[13;2ud\re\rf"): + self.assertEqual(verdict(case, "legacy", {**direct, "hex": observed.encode().hex()})[0], "unsupported") + + def test_mouse_focus_refresh_requires_reports_on_both_sides(self): + case = self.cases["mouse-focus-refresh"] + mouse = b"\x1b[<35;10;5M\x1b[<0;10;5M\x1b[<0;10;5m\x1b[<64;10;5M" + good = b"a" + mouse + b"bc" + mouse + b"d" + self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": good.hex()})[0], "pass") + for bad in (b"ab" + b"c" + mouse + b"d", b"a" + mouse + b"bcd"): + self.assertEqual(verdict(case, "legacy", {**self.evidence, "hex": bad.hex()})[0], "fail") + + def test_qualification_matrix_uses_observed_results_only(self): + observations = [ + {"host": "stable", "case": "shift-enter", "path": "herdr", "mode": "mok2", "status": "pass"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "legacy", "status": "unsupported"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "pass"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "inconclusive"}, + ] + rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable"], "observations": observations})} + self.assertEqual(rows["Shift+Enter"], ("PASS", "X - becomes Enter", "PASS**")) + self.assertEqual(rows["Multiline paste"], ("NOT TESTED", "NOT TESTED", "NOT TESTED")) + observations.append({"host": "stable", "case": "dead-acute", "path": "herdr", "mode": "mok2", "status": "pass"}) + observations.extend([ + {"host": "stable", "case": "letter-a", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "shift-enter", "path": "direct", "mode": "legacy", "width": 80, "status": "unsupported"}, + {"host": "stable", "case": "paste-lf", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "mouse-focus-refresh", "path": "herdr", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "mouse-focus-refresh", "path": "direct", "mode": "legacy", "width": 80, "status": "pass"}, + {"host": "stable", "case": "mouse-focus-refresh", "path": "direct", "mode": "kitty", "width": 80, "status": "pass"}, + ]) + rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable"], "observations": observations})} + self.assertEqual(rows["Dead-key composition"][0], "PASS") + self.assertEqual(rows["Resize 120 -> 80"][1], "PASS") + self.assertEqual(rows["Mouse after resize"], ("PASS", "PASS", "PASS")) + self.assertEqual(rows["AltGr"], ("MANUAL", "MANUAL", "MANUAL")) + self.assertEqual(rows["IME composition"], ("MANUAL", "MANUAL", "MANUAL")) + + def test_qualification_matrix_requires_every_selected_channel(self): + observations = [ + {"host": "stable", "case": "shift-enter", "path": "herdr", "mode": "mok2", "status": "pass"}, + ] + for result in ({"channels": ["stable", "preview"], "observations": observations}, + {"observations": observations}): + rows = {row[0]: row[1:] for row in qualification_matrix(result)} + self.assertEqual(rows["Shift+Enter"][0], "PARTIAL") + + def test_release_matrix_uses_its_paired_hosts(self): + observations = [{"host": spec["channel"], "path": spec["path"], "mode": spec["mode"], + "case": case, "status": "pass", "width": width} + for spec in self.matrix["release_plan"] for width in (120, 80) for case in spec["cases"]] + nominal_only = [row for row in observations if row["width"] == 120] + rows = {row[0]: row[1:] for row in qualification_matrix({"campaign": "release", "observations": nominal_only})} + self.assertEqual(rows["Resize 120 -> 80"], ("PARTIAL", "PARTIAL")) + rows = {row[0]: row[1:] for row in qualification_matrix({"campaign": "release", "observations": observations})} + for name, cells in rows.items(): + self.assertEqual(cells, ("MANUAL", "MANUAL") if name in {"PageUp/PageDown scroll", "AltGr", "IME composition"} + else ("PASS", "PASS"), name) + failure = next(row for row in observations if row["host"] == "stable" and row["mode"] == "mok2" + and row["case"] == "letter-a" and row["width"] == 120) + failure["status"] = "fail" + rows = {row[0]: row[1:] for row in qualification_matrix({"campaign": "release", "observations": observations})} + self.assertEqual(rows["Printable keys"][0], "FAIL") + self.assertEqual(rows["Resize 120 -> 80"][0], "FAIL") + self.assertEqual(rows["PageUp/PageDown scroll"], ("MANUAL", "MANUAL")) + failure["status"] = "pass" + missing_mode = [row for row in observations if not (row["host"] == "stable" and row["mode"] == "mok2" + and row["case"] == "paste-lf")] + rows = {row[0]: row[1:] for row in qualification_matrix({"campaign": "release", "observations": missing_mode})} + self.assertEqual(rows["Multiline paste"][0], "PARTIAL") + stable_partial = next(row for row in observations if row["host"] == "stable" and row["mode"] == "mok2" + and row["case"] == "paste-lf" and row["width"] == 120) + for status, expected in (("inconclusive", "INCONCLUSIVE"), ("unsupported", "UNSUPPORTED"), + ("not_run", "PARTIAL")): + stable_partial["status"] = status + rows = {row[0]: row[1:] for row in qualification_matrix({"campaign": "release", "observations": observations})} + self.assertEqual(rows["Multiline paste"][0], expected) + stable_partial["status"] = "pass" + partial = next(row for row in observations if row["host"] == "preview" and row["mode"] == "kitty" + and row["case"] == "shift-enter" and row["width"] == 80) + partial["status"] = "inconclusive" + rows = {row[0]: row[1:] for row in qualification_matrix({"campaign": "release", "observations": observations})} + self.assertEqual(rows["Shift+Enter"][1], "INCONCLUSIVE") + + def test_run_spec_planning_does_not_require_other_combinations(self): + document = {"channels": ["stable"], "widths": [80], "heights": [24], + "run_specs": [{"channel": "stable", "path": "herdr", "mode": "legacy", "cases": ["letter-a"]}], + "observations": [{"host": "stable", "path": "herdr", "mode": "legacy", "phase": 1, + "width": 120, "height": 30, "case": "letter-a", "status": "not_run"}]} + self.assertEqual(summarize(document)["coverage_missing"], 2) + document["observations"][0]["mode"] = "kitty" + with self.assertRaisesRegex(ValueError, "outside the declared run matrix"): + summarize(document) + + def test_direct_legacy_limit_requires_unsupported_from_every_channel(self): + observations = [{"host": host, "case": "shift-enter", "path": "direct", "mode": "legacy", "status": status} + for host, status in (("stable", "unsupported"), ("preview", "not_run"))] + rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable", "preview"], "observations": observations})} + self.assertEqual(rows["Shift+Enter"][1], "PARTIAL") + + def test_qualification_matrix_keeps_proven_input_visible_when_geometry_is_unavailable(self): + observations = [{"host": host, "case": case, "path": "herdr", "mode": "legacy", "status": "pass", "width": 120} + for host in ("stable", "preview") for case in ("letter-a", "shift-letter")] + observations += [{"host": host, "case": "letter-a", "path": "herdr", "mode": "legacy", "status": "not_run", "width": 160} + for host in ("stable", "preview")] + observations += [{"host": host, "case": "shift-enter", "path": "direct", "mode": "legacy", "status": status} + for host in ("stable", "preview") for status in ("unsupported", "not_run")] + observations += [{"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "inconclusive"}, + {"host": "preview", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "pass"}, + {"host": "preview", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "not_run"}] + result = {"channels": ["stable", "preview"], "observations": observations} + rows = {row[0]: row[1:] for row in qualification_matrix(result)} + self.assertEqual(rows["Printable keys"][0], "PARTIAL") + self.assertEqual(rows["Shift+Enter"][1:], ("X - becomes Enter", "INCONCLUSIVE")) + self.assertEqual(rows["Multiline paste"][0], "NOT TESTED") + self.assertEqual(rows["Resize 120 -> 80"][0], "NOT TESTED") + observations[3]["status"] = "not_run" # Preview has no passing shift-letter observation. + self.assertEqual({row[0]: row[1] for row in qualification_matrix(result)}["Printable keys"], "NOT TESTED") + observations[3]["status"] = "fail" + self.assertEqual({row[0]: row[1] for row in qualification_matrix(result)}["Printable keys"], "FAIL") + + def test_herdr_protocol_label_requires_every_run_to_prove_transport(self): + result = {"hosts": [{"channel": "stable", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"}, + {"nonce": "two", "path": "herdr", "mode": "native"}]}], + "observations": [{"host": "stable", "nonce": "one", "path": "herdr", "mode": "native", + "input_reader": "windows-console", + "input_transport": "win32-serialized"}]} + self.assertEqual(herdr_protocol_label(result), "Herdr default (UNKNOWN)*") + result["observations"].append({"host": "stable", "nonce": "two", "path": "herdr", "mode": "native", + "input_reader": "windows-console", + "input_transport": "win32-serialized"}) + self.assertEqual(herdr_protocol_label(result), "Win32 (Herdr)*") + result["hosts"].append({"channel": "preview", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"}]}) + self.assertEqual(herdr_protocol_label(result), "Herdr default (UNKNOWN)*") + + def test_release_success_requires_stable_win32_runtime_evidence(self): + observations = [{**self.evidence, "case": "letter-a", "host": "stable", "path": "herdr", "mode": "legacy", + "hex": "61", "phase": phase, "width": width, "height": height, + "outer_geometry": [width, height], "final_outer_geometry": [width, height], + "nonce": "owned", "capture_id": str(phase)} + for phase, (width, height) in enumerate(((120, 30), (80, 24), (80, 30)), 1)] + run = {"nonce": "owned", "path": "herdr", "mode": "legacy", "pid": 123, "hwnd": 456, + "elevated": False, "image_identity": "image-s", "installation_identity": "install-s"} + document = {"campaign": "release", "channels": ["stable"], "widths": [80], "heights": [24], + "run_specs": [{"channel": "stable", "path": "herdr", "mode": "legacy", "cases": ["letter-a"]}], + "observations": observations, "hosts": [{"channel": "stable", "runs": [run]}], + "controller_elevated": False} + incomplete = summarize(document) + self.assertEqual(incomplete["counts"]["pass"], 3) + self.assertEqual(incomplete["coverage_missing"], 0) + self.assertFalse(incomplete["observed_checks_passed"]) + observations[0].update(input_reader="windows-console", input_transport="win32-serialized") + self.assertTrue(summarize(document)["observed_checks_passed"]) + + def test_empty_partial_and_duplicate_reports_never_become_green(self): + self.assertFalse(summarize({})["observed_checks_passed"]) + row = {**self.evidence, "case": "letter-a", "host": "stable", "path": "herdr", "mode": "legacy", "hex": "61", "phase": 1, + "width": 120, "height": 30, "outer_geometry": [120, 30], "final_outer_geometry": [120, 30]} + self.assertFalse(summarize({"observations": [row]})["observed_checks_passed"]) + with self.assertRaisesRegex(ValueError, "Duplicate"): + summarize({"observations": [row, row]}) + later = {**row, "phase": 2, "width": 80, "height": 24, "outer_geometry": [80, 24], "final_outer_geometry": [80, 24]} + host = {"channel": "stable", "runs": [{"nonce": "owned", "path": "herdr", "mode": "legacy", "pid": 123, "hwnd": 456, + "elevated": False, "image_identity": "image-s", "installation_identity": "install-s"}]} + row.update(nonce="owned", capture_id="first") + later.update(nonce="owned", capture_id="second") + self.assertEqual(summarize({"observations": [row, later], "hosts": [host], "controller_elevated": False})["counts"]["pass"], 2) + stale = {**later, "capture_id": "first"} + self.assertEqual(summarize({"observations": [row, stale], "hosts": [host], "controller_elevated": False})["counts"]["inconclusive"], 1) + forged_hosts = [{"channel": name, "runs": [{}]} for name in ("stable", "preview")] + partial = summarize({"observations": [row], "hosts": forged_hosts}) + self.assertFalse(partial["observed_checks_passed"]) + self.assertGreater(partial["coverage_missing"], 0) + for status in ["unsupported", "not_run", "inconclusive"]: + self.assertEqual(verdict(self.cases["letter-a"], "legacy", {**row, "status": status})[0], status) + + def test_malformed_native_records_are_inconclusive_not_exceptions(self): + evidence = {**self.evidence, "scans": [30]} + for records in [None, 7, "records", [None], [[1, 1, 1, 65, 30, 97, None]], + [[True, 1, 1, 65, 30, 97, 0]], [[1, 1, 1, 65, 30, 97, "0"]]]: + self.assertEqual(verdict(self.cases["letter-a"], "native", {**evidence, "records": records})[0], "inconclusive") + + def test_known_host_gap_only_exempts_observed_terminal_versions_and_cases(self): + row = {**self.evidence, "case": "shift-enter", "host": "stable", "path": "direct", "mode": "mok2", "hex": "0d", "phase": 1, + "width": 120, "height": 30, "outer_geometry": [120, 30], "final_outer_geometry": [120, 30], + "nonce": "owned", "capture_id": "fresh"} + for path, version, raw, expected in [("direct", "1.24.11911.0", "0d", "unsupported"), + ("direct", "1.25.1912.0", "0d", "unsupported"), + ("direct", "1.26.1.0", "0d", "fail"), + ("herdr", "1.24.11911.0", "0d", "fail"), + ("direct", "1.24.11911.0", "", "fail")]: + observation = {**row, "path": path, "hex": raw} + run = {"nonce": "owned", "path": path, "mode": "mok2", "pid": 123, "hwnd": 456, "terminal_version": version, + "elevated": False, "image_identity": "image-s", "installation_identity": "install-s"} + document = {"observations": [observation], "hosts": [{"channel": "stable", "runs": [run]}], "controller_elevated": False} + result = summarize(document)["observations"][0] + self.assertEqual(result["status"], expected) + self.assertEqual(result["failure_scope"], "direct_host" if path == "direct" else "through_herdr_not_yet_attributed") + document["controller_elevated"] = True + self.assertEqual(summarize(document)["observations"][0]["status"], "inconclusive") + document["controller_elevated"] = False + run["elevated"] = True + self.assertEqual(summarize(document)["observations"][0]["status"], "inconclusive") + + def test_direct_mok_legacy_fallback_is_case_and_version_bounded(self): + case = self.cases["shift-tab"] + direct = {**self.evidence, "path": "direct", "mode": "mok2", "hex": "1b5b5a"} + self.assertTrue(known_host_gap(direct, case, "1.25.2607.10002")) + self.assertFalse(known_host_gap(direct, case, "1.26.0.0")) + self.assertFalse(known_host_gap({**direct, "path": "herdr"}, case, "1.25.2607.10002")) + self.assertFalse(known_host_gap({**direct, "hex": "1b5b32373b323b397e"}, case, "1.25.2607.10002")) + + def test_duplicate_channel_identity_is_rejected_at_both_stages(self): + hosts = [{"channel": name, "launcher_identity": name + "-exe", "installation_identity": name + "-dir", + "runs": [{"image_identity": name + "-image", "installation_identity": name + "-dir", "process_identity": name + "-pid/start"}]} + for name in ("stable", "preview")] + self.assertEqual(channel_identity_errors(hosts), []) + for field in ("launcher_identity", "installation_identity"): + duplicate = copy.deepcopy(hosts) + duplicate[1][field] = duplicate[0][field] + self.assertTrue(channel_identity_errors(duplicate)) + self.assertTrue(summarize({"hosts": duplicate})["errors"]) + for field in ("image_identity", "installation_identity", "process_identity"): + duplicate = copy.deepcopy(hosts) + duplicate[1]["runs"][0][field] = duplicate[0]["runs"][0][field] + self.assertTrue(channel_identity_errors(duplicate)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/windows_input/Bootstrap.ps1 b/scripts/windows_input/Bootstrap.ps1 new file mode 100644 index 00000000..6008c64f --- /dev/null +++ b/scripts/windows_input/Bootstrap.ps1 @@ -0,0 +1,45 @@ +#Requires -Version 7.0 +param([Parameter(Mandatory)][string] $PlanPath) +. "$PSScriptRoot/Common.ps1" +$plan = Read-GauntletJson $PlanPath +# Terminal activation can inherit another process's environment. Normalize here too. +foreach ($item in @(Get-ChildItem Env:)) { + if ($item.Name -like 'HERDR_*' -or $item.Name -in @('SSH_CONNECTION', 'SSH_TTY')) { Remove-Item "Env:$($item.Name)" } +} +$env:HERDR_CONFIG_PATH = $plan.config +$env:XDG_CONFIG_HOME = $plan.config_home +$env:HERDR_SESSION = $plan.session +if ($plan.profile -ne 'default') { $env:HERDR_WINDOWS_INPUT_PROBE = $plan.profile } +Add-Type -Path "$PSScriptRoot/Native.cs" +$before = [HerdrInputGauntlet.ConsoleProbe]::Geometry() +$child = $null +try { + if ($plan.path -in @('herdr', 'herdr-remote')) { + $child = New-GauntletProcess $plan.exe @('--session', $plan.session) $plan + } else { + $child = New-GauntletProcess $plan.pwsh @('-NoProfile', '-File', "$PSScriptRoot/Probe.ps1", '-PlanPath', $PlanPath) $plan + } + $sequence = 0 + while (-not $child.HasExited -and (Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'stop'))) { + $sequence++ + Write-GauntletJson (Join-Path $plan.work 'outer.json') @{ + nonce = $plan.nonce; pid = $PID; child_pid = $child.Id; sequence = $sequence + geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); before = $before + } + Start-Sleep -Milliseconds 100 + } +} finally { + $cleanup = @() + if ($null -ne $child) { + # Allow normal detach / observer disposal before forcing this owned child down. + if (-not $child.WaitForExit(5000)) { + $cleanup += 'Child required forced termination; normal console restoration was not qualified' + $child.Kill($true) + if (-not $child.WaitForExit(5000)) { $cleanup += 'Child remained active after forced termination' } + } + $child.Dispose() + } + Write-GauntletJson (Join-Path $plan.work 'bootstrap-exit.json') @{ + nonce = $plan.nonce; before = $before; after = [HerdrInputGauntlet.ConsoleProbe]::Geometry(); cleanup_errors = $cleanup + } +} diff --git a/scripts/windows_input/Common.ps1 b/scripts/windows_input/Common.ps1 new file mode 100644 index 00000000..1fd4943f --- /dev/null +++ b/scripts/windows_input/Common.ps1 @@ -0,0 +1,97 @@ +# Shared test helpers. Dot-sourcing does not touch the desktop or console. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-GauntletJson($Path, $Value) { + $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp" + [IO.File]::WriteAllText($temporary, ($Value | ConvertTo-Json -Depth 30), [Text.UTF8Encoding]::new($false)) + for ($attempt = 0; ; $attempt++) { + try { [IO.File]::Move($temporary, $Path, $true); return } + catch [IO.IOException], [UnauthorizedAccessException] { + if ($attempt -ge 99) { throw } + Start-Sleep -Milliseconds 10 + } + } +} + +function Read-GauntletJson($Path) { + if (Test-Path -LiteralPath $Path) { + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + try { + $reader = [IO.StreamReader]::new($stream) + try { return $reader.ReadToEnd() | ConvertFrom-Json -AsHashtable } + finally { $reader.Dispose() } + } finally { $stream.Dispose() } + } + return $null +} + +function Quote-GauntletPS([string] $Text) { return "'" + $Text.Replace("'", "''") + "'" } + +function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) { + $info = [Diagnostics.ProcessStartInfo]::new($Exe) + $info.UseShellExecute = $false + foreach ($argument in $Arguments) { $info.ArgumentList.Add([string]$argument) } + foreach ($name in @($info.Environment.Keys)) { + if ($name -like 'HERDR_*' -or $name -in @('SSH_CONNECTION', 'SSH_TTY')) { $null = $info.Environment.Remove($name) } + } + if ($null -ne $Plan) { + $info.Environment['HERDR_CONFIG_PATH'] = $Plan.config + $info.Environment['XDG_CONFIG_HOME'] = $Plan.config_home + $info.Environment['HERDR_SESSION'] = $Plan.session + if ($Plan.profile -ne 'default') { $info.Environment['HERDR_WINDOWS_INPUT_PROBE'] = $Plan.profile } + if ($Plan.path -in @('herdr', 'herdr-remote')) { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + if ($Plan.path -in @('herdr', 'herdr-remote')) { + # Enable the mapper's client-event trace so a report can distinguish a + # terminal-issued paste from a bridge reaction (see README, #4314). + $info.Environment['HERDR_WINDOWS_INPUT_TRACE'] = '1' + $info.Environment['HERDR_LOG'] = 'herdr=info' + } + if ($Plan.path -eq 'herdr-remote') { + $info.Environment['HERDR_REMOTE_KEYBINDINGS'] = 'local' + } + } + $info.RedirectStandardOutput = $Capture.IsPresent + $info.RedirectStandardError = $Capture.IsPresent + $info.CreateNoWindow = $Capture.IsPresent + return [Diagnostics.Process]::Start($info) +} + +function Invoke-GauntletProcess($Exe, $Arguments, $Plan = $null, [int] $Timeout = 30) { + $process = New-GauntletProcess $Exe $Arguments $Plan -Capture + try { + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $timer = [Diagnostics.Stopwatch]::StartNew() + if (-not $process.WaitForExit($Timeout * 1000)) { + $process.Kill($true) + throw "Command timed out: $Exe $($Arguments -join ' ')" + } + $remaining = [Math]::Max(0, $Timeout * 1000 - [int]$timer.ElapsedMilliseconds) + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), $remaining)) { + throw "Output streams stayed open after exit: $Exe $($Arguments -join ' ')" + } + if ($process.ExitCode -ne 0) { throw "Command failed ($($process.ExitCode)): $Exe`n$($stderr.GetAwaiter().GetResult())" } + return $stdout.GetAwaiter().GetResult() + } finally { $process.Dispose() } +} + +function Update-GauntletLease($Root) { + [IO.File]::WriteAllText((Join-Path $Root 'lease'), [DateTime]::UtcNow.ToString('O')) +} + +function Test-GauntletLease($Root) { + $lease = Get-Item -LiteralPath (Join-Path $Root 'lease') -ErrorAction SilentlyContinue + return $null -ne $lease -and ([DateTime]::UtcNow - $lease.LastWriteTimeUtc).TotalSeconds -lt 90 +} + +function Wait-GauntletJson($Path, $Root, $Predicate, [int] $Timeout = 20) { + $deadline = [DateTime]::UtcNow.AddSeconds($Timeout) + do { + Update-GauntletLease $Root + $value = Read-GauntletJson $Path + if ($null -ne $value -and (& $Predicate $value)) { return $value } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + throw "Timed out waiting for fresh acknowledgement: $Path" +} diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs new file mode 100644 index 00000000..9d635753 --- /dev/null +++ b/scripts/windows_input/Native.cs @@ -0,0 +1,508 @@ +// Test-only Windows desktop/console adapter. No calls occur when this file is compiled. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace HerdrInputGauntlet { + [ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] + class ApplicationActivationManager { } + + [ComImport, Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IApplicationActivationManager { + [PreserveSig] + int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, + [MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId); + } + + public static class Desktop { + [StructLayout(LayoutKind.Sequential)] public struct Point { public int X, Y; } + [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; } + [StructLayout(LayoutKind.Sequential)] struct Mouse { public int X, Y; public uint Data, Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Sequential)] struct Key { public ushort Vk, Scan; public uint Flags, Time; public UIntPtr Extra; } + [StructLayout(LayoutKind.Explicit)] struct Union { [FieldOffset(0)] public Mouse Mouse; [FieldOffset(0)] public Key Key; } + [StructLayout(LayoutKind.Sequential)] struct Input { public uint Type; public Union Data; } + delegate bool EnumProc(IntPtr hwnd, IntPtr arg); + delegate IntPtr HookProc(int code,IntPtr message,IntPtr data); + [StructLayout(LayoutKind.Sequential)] struct Message { public IntPtr Window; public uint Id; public UIntPtr WParam; public IntPtr LParam; public uint Time; public Point Point; public uint Private; } + [DllImport("user32.dll",SetLastError=true)] static extern IntPtr SetWindowsHookEx(int kind,HookProc callback,IntPtr module,uint thread); + [DllImport("user32.dll")] static extern bool UnhookWindowsHookEx(IntPtr hook); + [DllImport("user32.dll")] static extern IntPtr CallNextHookEx(IntPtr hook,int code,IntPtr message,IntPtr data); + [DllImport("user32.dll")] static extern int GetMessage(out Message message,IntPtr window,uint min,uint max); + [DllImport("user32.dll")] static extern bool PostThreadMessage(uint thread,uint message,UIntPtr wparam,IntPtr lparam); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr GetModuleHandle(string name); + static volatile bool stopped; + static Thread stopThread; + static uint stopThreadId; + static HookProc stopHook; + static readonly ManualResetEventSlim stopReady=new ManualResetEventSlim(); + static string stopError; + public static void StartEmergencyStop() { + stopThread=new Thread(()=> { + IntPtr hook=IntPtr.Zero; + try { + stopThreadId=GetCurrentThreadId(); + stopHook=(code,message,data)=> { + if(code>=0 && (message.ToInt64()==0x100 || message.ToInt64()==0x104) && Marshal.ReadInt32(data)==0x7B) stopped=true; + return CallNextHookEx(IntPtr.Zero,code,message,data); + }; + hook=SetWindowsHookEx(13,stopHook,GetModuleHandle(null),0); + if(hook==IntPtr.Zero) throw new Win32Exception(); + stopReady.Set(); + Message message; + while(GetMessage(out message,IntPtr.Zero,0,0)>0) {} + } catch(Exception e) { stopError=e.Message; stopped=true; stopReady.Set(); } + finally { if(hook!=IntPtr.Zero) UnhookWindowsHookEx(hook); } + }) { IsBackground=true }; + stopThread.Start(); + if(!stopReady.Wait(3000) || stopError!=null) throw new Exception("Cannot install emergency-stop latch: "+stopError); + } + public static void StopEmergencyStop() { + if(stopThread!=null) { PostThreadMessage(stopThreadId,0x12,UIntPtr.Zero,IntPtr.Zero); stopThread.Join(1000); } + } + [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,int pid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,out uint value,int size,out int returned); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + [StructLayout(LayoutKind.Sequential)] struct FileInformation { + public uint Attributes,CreationLow,CreationHigh,AccessLow,AccessHigh,WriteLow,WriteHigh; + public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow; + } + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(Microsoft.Win32.SafeHandles.SafeFileHandle file,out FileInformation info); + public static string FileIdentity(string path) { + using(var file=CreateFile(path,0,7,IntPtr.Zero,3,0x02000000,IntPtr.Zero)) { + FileInformation info; + if(file.IsInvalid || !GetFileInformationByHandle(file,out info)) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot establish Terminal file/installation identity"); + return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8"); + } + } + public static void AssertNotElevated(int pid) { + var process=OpenProcess(0x1000,false,pid); + if(process==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input"); + IntPtr token=IntPtr.Zero; + try { + uint elevated; int returned; + if(!OpenProcessToken(process,8,out token) || !GetTokenInformation(token,20,out elevated,4,out returned) || returned!=4) + throw new Win32Exception(Marshal.GetLastWin32Error(),"Cannot verify process elevation; refusing desktop input"); + if(elevated!=0) throw new Exception("Elevated controller/Terminal is not allowed. Start PowerShell and Terminal without Run as administrator."); + } finally { if(token!=IntPtr.Zero) CloseHandle(token); CloseHandle(process); } + } + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc callback, IntPtr arg); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr hwnd, StringBuilder value, int count); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + [DllImport("user32.dll")] static extern IntPtr GetKeyboardLayout(uint thread); + [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] static extern bool GetCursorPos(out Point point); + [DllImport("user32.dll")] static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] static extern int GetSystemMetrics(int index); + [DllImport("user32.dll")] static extern short GetAsyncKeyState(int vk); + [DllImport("user32.dll")] static extern uint MapVirtualKeyEx(uint key, uint kind, IntPtr layout); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int ToUnicodeEx(uint key,uint scan,byte[] state,StringBuilder text,int count,uint flags,IntPtr layout); + [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] events, int size); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); + [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int width, int height, uint flags); + [DllImport("user32.dll",EntryPoint="CountClipboardFormats",SetLastError=true)] static extern int NativeCountClipboardFormats(); + public static int CountClipboardFormats() { + int count=NativeCountClipboardFormats(); + if(count==0) { + int error=Marshal.GetLastWin32Error(); + if(error!=0) throw new Win32Exception(error,"Clipboard format count failed"); + } + return count; + } + [DllImport("user32.dll")] public static extern uint GetClipboardSequenceNumber(); + [DllImport("user32.dll")] static extern IntPtr GetClipboardOwner(); + [DllImport("user32.dll")] static extern bool OpenClipboard(IntPtr owner); + [DllImport("user32.dll")] static extern bool CloseClipboard(); + [DllImport("user32.dll")] static extern bool EmptyClipboard(); + [DllImport("user32.dll")] static extern IntPtr SetClipboardData(uint format,IntPtr value); + [DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern uint RegisterClipboardFormat(string format); + [DllImport("kernel32.dll")] static extern IntPtr GlobalAlloc(uint flags,UIntPtr size); + [DllImport("kernel32.dll")] static extern IntPtr GlobalLock(IntPtr memory); + [DllImport("kernel32.dll")] static extern bool GlobalUnlock(IntPtr memory); + [DllImport("kernel32.dll")] static extern IntPtr GlobalFree(IntPtr memory); + + static string QuoteArgument(string value) { + if(value.Length>0 && value.IndexOfAny(new[]{' ', '\t', '\n', '\v', '"'})<0) return value; + var result=new StringBuilder("\""); + int slashes=0; + foreach(char c in value) { + if(c=='\\') { slashes++; continue; } + if(c=='"') result.Append('\\',slashes*2+1); + else result.Append('\\',slashes); + result.Append(c); slashes=0; + } + result.Append('\\',slashes*2).Append('"'); + return result.ToString(); + } + public static int ActivateApplication(string appUserModelId,string[] arguments) { + var manager=(IApplicationActivationManager)new ApplicationActivationManager(); + uint pid; + int result=manager.ActivateApplication(appUserModelId,string.Join(" ",Array.ConvertAll(arguments,QuoteArgument)),0,out pid); + if(result<0) Marshal.ThrowExceptionForHR(result); + return checked((int)pid); + } + public static int ClearClipboardForRun() { + if(!OpenClipboard(IntPtr.Zero)) throw new Exception("Clipboard busy; cannot start paste qualification"); + try { + int formats=CountClipboardFormats(); + if(formats!=0 && !EmptyClipboard()) throw new Exception("Clipboard clear failed"); + return formats; + } finally { CloseClipboard(); } + } + + public static uint SetEmptyClipboard(IntPtr owner,string text) { + if(owner==IntPtr.Zero) throw new Exception("Clipboard owner is required"); + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; refusing replacement"); + IntPtr memory=IntPtr.Zero; + try { + if(CountClipboardFormats()!=0) throw new Exception("Clipboard changed or contains user data; refusing replacement"); + byte[] data=Encoding.Unicode.GetBytes(text+"\0"); + memory=GlobalAlloc(2,new UIntPtr((uint)data.Length)); + if(memory==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + var pointer=GlobalLock(memory); + if(pointer==IntPtr.Zero) throw new Exception("Clipboard lock failed"); + try { Marshal.Copy(data,0,pointer,data.Length); } finally { GlobalUnlock(memory); } + if(!EmptyClipboard() || SetClipboardData(13,memory)==IntPtr.Zero) throw new Exception("Clipboard write failed"); + memory=IntPtr.Zero; // ownership transferred to Windows + } finally { if(memory!=IntPtr.Zero) GlobalFree(memory); CloseClipboard(); } + return AdoptClipboardLease(owner); + } + public static uint SetEmptyClipboardImage(IntPtr owner,string text) { + if(owner==IntPtr.Zero) throw new Exception("Clipboard owner is required"); + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; refusing replacement"); + IntPtr pngMemory=IntPtr.Zero,textMemory=IntPtr.Zero; + bool complete=false; + try { + if(CountClipboardFormats()!=0) throw new Exception("Clipboard changed or contains user data; refusing replacement"); + byte[] png=Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAANSURBVBhXY/jPwPAfAAUAAf+mXJtdAAAAAElFTkSuQmCC"); + pngMemory=GlobalAlloc(2,new UIntPtr((uint)png.Length)); + if(pngMemory==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + var pointer=GlobalLock(pngMemory); + if(pointer==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + try { Marshal.Copy(png,0,pointer,png.Length); } finally { GlobalUnlock(pngMemory); } + if(!EmptyClipboard()) throw new Exception("Clipboard clear failed"); + uint pngFormat=RegisterClipboardFormat("PNG"); + if(pngFormat==0 || SetClipboardData(pngFormat,pngMemory)==IntPtr.Zero) throw new Exception("Clipboard image write failed"); + pngMemory=IntPtr.Zero; + if(text!=null) { + byte[] data=Encoding.Unicode.GetBytes(text+"\0"); + textMemory=GlobalAlloc(2,new UIntPtr((uint)data.Length)); + if(textMemory==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + pointer=GlobalLock(textMemory); + if(pointer==IntPtr.Zero) throw new Exception("Clipboard allocation failed"); + try { Marshal.Copy(data,0,pointer,data.Length); } finally { GlobalUnlock(textMemory); } + if(SetClipboardData(13,textMemory)==IntPtr.Zero) throw new Exception("Clipboard text write failed"); + textMemory=IntPtr.Zero; + } + complete=true; + } finally { + if(!complete) EmptyClipboard(); + if(pngMemory!=IntPtr.Zero) GlobalFree(pngMemory); + if(textMemory!=IntPtr.Zero) GlobalFree(textMemory); + CloseClipboard(); + } + return AdoptClipboardLease(owner); + } + static uint AdoptClipboardLease(IntPtr owner) { + // Closing can synthesize additional formats and advance the sequence. + uint sequence=GetClipboardSequenceNumber(); + try { + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership"); + try { + if(GetClipboardOwner()!=owner || GetClipboardSequenceNumber()!=sequence) + throw new Exception("Clipboard ownership changed; refusing cleanup lease"); + return sequence; + } finally { CloseClipboard(); } + } catch(Exception error) { + if(!ClearOwnedClipboard(owner,sequence)) + throw new Exception("Could not clean clipboard after ownership verification failed",error); + throw; + } + } + public static bool ClearOwnedClipboard(IntPtr owner,uint sequence) { + if(owner==IntPtr.Zero) return false; + if(!OpenClipboard(IntPtr.Zero)) return false; + try { return GetClipboardSequenceNumber()!=sequence || GetClipboardOwner()!=owner || EmptyClipboard(); } + finally { CloseClipboard(); } + } + public static string Title(IntPtr hwnd) { var text=new StringBuilder(1024); GetWindowText(hwnd,text,text.Capacity); return text.ToString(); } + public static IntPtr Find(string nonce) { + var matches=new List(); + EnumWindows((h,a)=> { if (Title(h).Contains(nonce)) matches.Add(h); return true; },IntPtr.Zero); + if(matches.Count>1) throw new Exception("Ambiguous test window identity"); + return matches.Count==1 ? matches[0] : IntPtr.Zero; + } + public static int Pid(IntPtr hwnd) { uint pid; GetWindowThreadProcessId(hwnd,out pid); return checked((int)pid); } + public static string Layout(IntPtr hwnd) { uint pid; return GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out pid)).ToInt64().ToString("x"); } + public static bool IsOwned(IntPtr hwnd, string nonce, int pid) { return hwnd!=IntPtr.Zero && Pid(hwnd)==pid && Title(hwnd).Contains(nonce); } + public static void Guard(IntPtr hwnd, string nonce, int pid) { + if(!IsOwned(hwnd,nonce,pid) || GetForegroundWindow()!=hwnd) throw new Exception("Lost owned test-window focus; injection aborted"); + AssertNotElevated(pid); + if(stopped || (GetAsyncKeyState(0x7B)&0x8000)!=0) throw new Exception("F12 emergency stop"); + } + public static void Focus(IntPtr hwnd, string nonce, int pid) { + if(!IsOwned(hwnd,nonce,pid)) throw new Exception("Lost window ownership"); + AssertNotElevated(pid); + var deadline=DateTime.UtcNow.AddSeconds(3); + do { + SetForegroundWindow(hwnd); + if(GetForegroundWindow()==hwnd) return; + Thread.Sleep(100); + } while(DateTime.UtcNow(); + foreach(int key in keys) scans.Add(Event((ushort)key,false,layout).Data.Key.Scan); + return scans.ToArray(); + } + static int[] LayoutChord(int vk,int modifiers) { + var keys=new List(); + if((modifiers&1)!=0) keys.Add(0x10); + if((modifiers&2)!=0) keys.Add(0x11); + if((modifiers&4)!=0) keys.Add(0x12); + keys.Add(vk); + return keys.ToArray(); + } + public static int[] DeadKeyChord(IntPtr hwnd,char accent) { + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + for(int modifiers=0;modifiers<8;modifiers++) for(uint vk=1;vk<255;vk++) { + uint scan=MapVirtualKeyEx(vk,4,layout); + if(scan==0) continue; + var state=new byte[256]; + if((modifiers&1)!=0) state[0x10]=0x80; + if((modifiers&2)!=0) state[0x11]=0x80; + if((modifiers&4)!=0) state[0x12]=0x80; + var text=new StringBuilder(4); + // Flag 4 discovers dead keys without changing keyboard state. + if(ToUnicodeEx(vk,scan,state,text,text.Capacity,4,layout)<0 && text.Length>0 && text[0]==accent) + return LayoutChord((int)vk,modifiers); + } + throw new Exception("Active layout has no "+accent+" dead key"); + } + // One balanced batch; no modifier is intentionally held between calls. + public static int Chord(IntPtr hwnd, string nonce, int pid, int[] keys) { + Guard(hwnd,nonce,pid); Neutral(); + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + var events=new List(); + foreach(int key in keys) events.Add(Event(checked((ushort)key),false,layout)); + for(int i=keys.Length-1;i>=0;i--) events.Add(Event(checked((ushort)keys[i]),true,layout)); + Guard(hwnd,nonce,pid); + uint sent=SendInput((uint)events.Count,events.ToArray(),Marshal.SizeOf()); + if(sent!=events.Count) { + // Release only keys whose down event was actually submitted and not yet released. + var held=new List(); + for(int i=0;i0) { + uint released=SendInput((uint)held.Count,held.ToArray(),Marshal.SizeOf()); + if(released!=held.Count) throw new Exception("SendInput cleanup incomplete: "+released+"/"+held.Count+" key-up events; release test modifiers manually before continuing"); + } + throw new Exception("SendInput incomplete (possible UIPI restriction): "+sent+"/"+events.Count); + } + return (int)sent; + } + public static long Cursor() { + Point point; + if(!GetCursorPos(out point)) throw new Win32Exception(); + return ((long)(uint)point.X<<32)|(uint)point.Y; + } + public static void RestoreCursor(long packed) { + if(!SetCursorPos(unchecked((int)(packed>>32)),unchecked((int)packed))) throw new Win32Exception(); + } + public static void MouseMoveInside(IntPtr hwnd,string nonce,int pid,int offset) { + Guard(hwnd,nonce,pid); Neutral(); Rect rect; + if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception(); + int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2; + if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior"); + int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79); + if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable"); + int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1)); + var input=new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } }; + if(SendInput(1,new[]{input},Marshal.SizeOf())!=1) throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed"); + } + public static void MouseClickWheelInside(IntPtr hwnd,string nonce,int pid,int offset) { + Guard(hwnd,nonce,pid); Neutral(); Rect rect; + if(!GetWindowRect(hwnd,out rect)) throw new Win32Exception(); + int x=(rect.Left+rect.Right)/2+offset,y=(rect.Top+rect.Bottom)/2; + if(x<=rect.Left+20 || x>=rect.Right-20 || y<=rect.Top+20 || y>=rect.Bottom-20) throw new Exception("Mouse target is outside the safe window interior"); + int left=GetSystemMetrics(76),top=GetSystemMetrics(77),width=GetSystemMetrics(78),height=GetSystemMetrics(79); + if(width<2 || height<2) throw new Exception("Virtual desktop geometry unavailable"); + int nx=(int)Math.Round((x-left)*65535.0/(width-1)),ny=(int)Math.Round((y-top)*65535.0/(height-1)); + var inputs=new[]{ + new Input { Type=0, Data=new Union { Mouse=new Mouse { X=nx,Y=ny,Flags=0xC001 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0002 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Flags=0x0004 } } }, + new Input { Type=0, Data=new Union { Mouse=new Mouse { Data=120,Flags=0x0800 } } } + }; + uint sent=SendInput((uint)inputs.Length,inputs,Marshal.SizeOf()); + if(sent!=inputs.Length) { + int error=Marshal.GetLastWin32Error(); + if(sent==2 && SendInput(1,new[]{inputs[2]},Marshal.SizeOf())!=1) + throw new Win32Exception(Marshal.GetLastWin32Error(),"Mouse injection failed after button-down; release the left mouse button manually"); + throw new Win32Exception(error,"Mouse click/wheel injection failed"); + } + } + public static void Resize(IntPtr hwnd,string nonce,int pid,int dx,int dy) { + Guard(hwnd,nonce,pid); Rect r; + if(!GetWindowRect(hwnd,out r)) throw new Win32Exception(); + int w=r.Right-r.Left+dx,h=r.Bottom-r.Top+dy; + if(w<300 || h<200 || w>10000 || h>10000) throw new Exception("Unsafe resize request"); + if(!SetWindowPos(hwnd,IntPtr.Zero,0,0,w,h,0x16)) throw new Win32Exception(); + } + } + + public sealed class ConsoleProbe : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Coord { public short X,Y; } + [StructLayout(LayoutKind.Sequential)] struct SmallRect { public short Left,Top,Right,Bottom; } + [StructLayout(LayoutKind.Sequential)] struct Info { public Coord Size,Cursor; public ushort Attributes; public SmallRect Window; public Coord Maximum; } + [StructLayout(LayoutKind.Explicit,Size=20)] struct Record { + [FieldOffset(0)] public ushort Type; + [FieldOffset(4)] public int Down; + [FieldOffset(8)] public ushort Repeat; + [FieldOffset(10)] public ushort Vk; + [FieldOffset(12)] public ushort Scan; + [FieldOffset(14)] public ushort Unicode; + [FieldOffset(16)] public uint Control; + } + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetConsoleMode(IntPtr handle,out uint mode); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetConsoleMode(IntPtr handle,uint mode); + [DllImport("kernel32.dll")] static extern bool GetConsoleScreenBufferInfo(IntPtr handle,out Info info); + [DllImport("kernel32.dll")] static extern uint GetConsoleCP(); + [DllImport("kernel32.dll")] static extern bool SetConsoleCP(uint cp); + [DllImport("kernel32.dll")] static extern bool ReadFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap); + [DllImport("kernel32.dll")] static extern bool WriteFile(IntPtr handle,byte[] buffer,uint size,out uint count,IntPtr overlap); + [DllImport("kernel32.dll")] static extern bool ReadConsoleInputW(IntPtr handle,[Out] Record[] records,uint size,out uint count); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll")] static extern IntPtr OpenThread(uint access,bool inherit,uint id); + [DllImport("kernel32.dll")] static extern bool CancelSynchronousIo(IntPtr thread); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + readonly object gate=new object(); + readonly List bytes=new List(); + readonly List records=new List(); + readonly IntPtr input=GetStdHandle(-10), output=GetStdHandle(-11); + readonly uint original, outputMode, cp; + readonly bool native; + volatile bool running=true; + Thread reader; + IntPtr threadHandle; + string error; + public string Error { + get { return Volatile.Read(ref error); } + private set { Volatile.Write(ref error,value); } + } + public ConsoleProbe(string mode) { + if(!GetConsoleMode(input,out original) || !GetConsoleMode(output,out outputMode)) throw new Exception("Probe needs a real console"); + cp=GetConsoleCP(); native=mode=="native"; + try { + if(!SetConsoleCP(65001) || !SetConsoleMode(output,outputMode|4) || !SetConsoleMode(input,native ? (original & ~0x247u)|0x98u : (original & ~7u)|0x200u)) throw new Win32Exception(); + Print("\x1b[?2004h"+(mode=="kitty"?"\x1b[>1u\x1b[?u":mode=="mok2"?"\x1b[>4;2m":"")); + reader=new Thread(Read) { IsBackground=true }; reader.Start(); + } catch { SetConsoleMode(input,original); SetConsoleMode(output,outputMode); SetConsoleCP(cp); throw; } + } + public static long[] Geometry() { + Info info; uint mode; + if(!GetConsoleScreenBufferInfo(GetStdHandle(-11),out info) || !GetConsoleMode(GetStdHandle(-10),out mode)) throw new Exception("Console geometry unavailable"); + return new long[]{ info.Window.Right-info.Window.Left+1,info.Window.Bottom-info.Window.Top+1,mode }; + } + public static void Print(string text) { + byte[] data=Encoding.UTF8.GetBytes(text); uint count; + if(!WriteFile(GetStdHandle(-11),data,(uint)data.Length,out count,IntPtr.Zero) || count!=data.Length) throw new Exception("Console output failed"); + } + public void SetKeyboardMode(string mode) { + string sequence="\x1b[4;0m"; + if(mode=="mok2") sequence+="\x1b[>4;2m"; + else if(mode=="kitty") sequence+="\x1b[>1u"; + else if(mode!="legacy") throw new Exception("Unknown keyboard mode transition"); + Print(sequence); + } + void Read() { + threadHandle=OpenThread(1,false,GetCurrentThreadId()); + if(threadHandle==IntPtr.Zero) { Error="Cannot acquire reader cancellation handle"; return; } + try { + while(running) { + if(Count()>1048576) { Error="Probe input limit exceeded"; break; } + uint count; + if(native) { + var batch=new Record[64]; + if(!ReadConsoleInputW(input,batch,64,out count)) { if(running) Error="ReadConsoleInputW failed"; break; } + lock(gate) for(int i=0;i(); + running=false; + if(threadHandle!=IntPtr.Zero) CancelSynchronousIo(threadHandle); + if(reader!=null && !reader.Join(1000)) errors.Add("Reader did not stop"); + if(threadHandle!=IntPtr.Zero) CloseHandle(threadHandle); + try { Print("\x1b[4;0m\x1b[?2004l\x1b[?1000l\x1b[?1006l"); } + catch(Exception e) { errors.Add(e.Message); } + if(!SetConsoleMode(input,original)) errors.Add("Input-mode restoration failed"); + if(!SetConsoleMode(output,outputMode)) errors.Add("Output-mode restoration failed"); + if(!SetConsoleCP(cp)) errors.Add("Code-page restoration failed"); + uint actual; + if(!GetConsoleMode(input,out actual) || actual!=original) errors.Add("Input mode differs after restoration"); + if(!GetConsoleMode(output,out actual) || actual!=outputMode) errors.Add("Output mode differs after restoration"); + if(GetConsoleCP()!=cp) errors.Add("Code page differs after restoration"); + CleanupErrors=errors.ToArray(); + } + } +} diff --git a/scripts/windows_input/Probe.ps1 b/scripts/windows_input/Probe.ps1 new file mode 100644 index 00000000..ad3a9c2b --- /dev/null +++ b/scripts/windows_input/Probe.ps1 @@ -0,0 +1,70 @@ +#Requires -Version 7.0 +param([Parameter(Mandatory)][string] $PlanPath) +. "$PSScriptRoot/Common.ps1" +$plan = Read-GauntletJson $PlanPath +Add-Type -Path "$PSScriptRoot/Native.cs" +$probe = $null +try { + $probe = [HerdrInputGauntlet.ConsoleProbe]::new($plan.mode) + [HerdrInputGauntlet.ConsoleProbe]::Print("`r`nHerdr input gauntlet observer $($plan.nonce)`r`n") + if ($plan.mode -eq 'kitty') { + $deadline = [DateTime]::UtcNow.AddSeconds(2) + while (-not $probe.Hex().Contains('1b5b3f3175') -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 50 } + } + Write-GauntletJson (Join-Path $plan.work 'ready.json') @{ + nonce = $plan.nonce; pid = $PID; mode = $plan.mode; geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry() + negotiation_hex = $probe.Hex(); kitty_acknowledged = $probe.Hex().Contains('1b5b3f3175') + } + # Negotiation replies are setup evidence, not input for the first case. + $probe.Clear() + $closedCount = 0 + $last = '' + while ((Test-GauntletLease $plan.root) -and -not (Test-Path (Join-Path $plan.work 'probe-stop'))) { + $request = Read-GauntletJson (Join-Path $plan.work 'request.json') + if ($null -ne $request -and $request.id -ne $last) { + if ($request.nonce -ne $plan.nonce) { throw 'Unexpected observer nonce' } + $last = $request.id + $quietReached = $false + if ($request.action -eq 'mouse-on') { + [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003h`e[?1006h") + Start-Sleep -Milliseconds 200 + } + if ($request.action -eq 'mouse-off') { + [HerdrInputGauntlet.ConsoleProbe]::Print("`e[?1003l`e[?1006l") + Start-Sleep -Milliseconds 100 + } + if ($request.action -eq 'set-mode') { + $probe.SetKeyboardMode([string]$request.value) + Start-Sleep -Milliseconds 200 + } + if ($request.action -eq 'begin' -and -not $probe.ClearIfCount($closedCount)) { throw 'Unexpected input arrived between captures' } + if ($request.action -eq 'end') { + # Capture trailing duplicates/releases as well as the first expected bytes. + $deadline = [DateTime]::UtcNow.AddSeconds(3) + $quiet = [DateTime]::UtcNow + $count = $probe.Count() + do { + Start-Sleep -Milliseconds 50 + $newCount = $probe.Count() + if ($newCount -ne $count) { $quiet = [DateTime]::UtcNow; $count = $newCount } + } while (([DateTime]::UtcNow - $quiet).TotalMilliseconds -lt 350 -and [DateTime]::UtcNow -lt $deadline) + $quietReached = ([DateTime]::UtcNow - $quiet).TotalMilliseconds -ge 350 + $closedCount = $probe.Count() + } + Write-GauntletJson (Join-Path $plan.work 'reply.json') @{ + id = $request.id; nonce = $plan.nonce; action = $request.action + hex = $probe.Hex(); records = @($probe.Records()); error = $probe.Error; quiet_reached = $quietReached + geometry = [HerdrInputGauntlet.ConsoleProbe]::Geometry() + } + } + Start-Sleep -Milliseconds 25 + } +} catch { + Write-GauntletJson (Join-Path $plan.work 'probe-error.json') @{ error = $_.ToString(); nonce = $plan.nonce } + throw +} finally { + if ($null -ne $probe) { + $probe.Dispose() + Write-GauntletJson (Join-Path $plan.work 'probe-exit.json') @{ nonce = $plan.nonce; cleanup_errors = @($probe.CleanupErrors) } + } +} diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md new file mode 100644 index 00000000..94b74299 --- /dev/null +++ b/scripts/windows_input/README.md @@ -0,0 +1,249 @@ +# Local Windows input gauntlet (experimental) + +This is local Windows qualification infrastructure, not a claim that all Windows +input works. It sends real scan-code gestures through a new Windows Terminal +window, both directly to an observer and through an attached Herdr client. It +does not substitute `pane send-keys` for host input. + +## Safety and prerequisites + +Use an **unlocked, isolated interactive desktop** where you will not do other +work during automated input. The runner changes foreground focus and window +size. `SendInput` has an unavoidable check-to-use focus race; a foreground check +is not an OS security boundary. The runner rejects an elevated controller and +checks the target Terminal process token before focus, resize, and injection. +An elevation-query failure is also a refusal, not permission to proceed. Start +PowerShell and Terminal **without Run as administrator**, on a dedicated desktop. There is no unattended CI job or runner permission +change in this implementation. + +Required: Windows, PowerShell **7** (`pwsh`), Python 3, Rust/Cargo, and both +Windows Terminal channels for the default run. Stable and Preview are discovered independently +through their installed packages. Explicit paths are available when discovery +does not work. No software is installed or updated. The actual Terminal process path/version is recorded, +not inferred from the requested channel or bundled OpenConsole version. Stable +and Preview must resolve to distinct installations: the runner compares Windows +file/directory identities before launch, then checks actual image, installation, +and PID/start-time identities after activation. Aliases cannot count one install +twice. The portable report validator rejects duplicate identities too. + +**F12** aborts before the next automatic gesture. Moving focus away also aborts +further automatic input. Each injected chord contains its own releases; there +are no intentionally held modifiers between calls. Do not hold physical modifiers +or mouse buttons while starting a run. Normal controller cancellation runs cleanup; +bootstrap/probe leases also expire if the controller disappears. Lease expiry is +not a replacement for a secure isolated desktop. + +`just test-windows-input` clears the clipboard once before the build when the +selected cases use it. **Save anything you need first:** existing text, images, +files, and other formats are discarded and cannot be restored. Direct script +calls need `-ClearClipboard` for the same behavior; without it, a nonempty +clipboard stops the run before the build. The runner writes its synthetic data +while holding the clipboard lock and clears it afterward only if its sequence +number is unchanged. A later user clipboard update is left alone. No original +clipboard contents are logged. + +## Run + +From the repository in PowerShell 7: + +```powershell +just test-windows-input +``` + +The default runs the original input matrix as three targeted observer runs: +Stable WT through Herdr with legacy and modifyOtherKeys observers, and Preview +WT through Herdr with Kitty. Herdr's outer reader stays on its default Win32 +policy. It tests the full selected cases at 120×30, then the existing input, +paste, and mouse sentinels at 80×24 and 80×30. PageUp/PageDown scrolling, +AltGr, and IME remain explicit manual checks. The printed matrix has one column +per host path; it does not combine Stable and Preview into one verdict. + +### Expected outcome + +A healthy default run prints PASS for every automated cell, MANUAL for the +physical checks, and no cleanup errors. The Stable column identifies itself as +Win32 only from captured runtime evidence. A missing host, missing Win32 reader +evidence, unreachable required size, or unavailable assertion exits `2`; an +assertion or cleanup failure exits `1`. Inspect the retained `report.json` for +exact observations. + +The recipe itself is the explicit opt-in to foreground input injection. It builds +the current checkout in release mode, stages that exact binary with the pinned +ConPTY runtime, and prints its path and hash. Use `-ExePath` only to compare a +specific old/new packaged binary. + +Or invoke the script directly: + +```powershell +pwsh -NoProfile -File scripts/test_windows_input.ps1 ` + -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection -ClearClipboard ` + -StablePath 'C:\TerminalStable\WindowsTerminal.exe' ` + -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe' +``` + +The default tests Herdr's **default** input policy. Pass `-Full` for the full +channel × path × mode × geometry diagnostic catalogue. Any explicit `-Profile`, +`-Modes`, `-Channels`, `-Paths`, `-Cases`, `-Widths`, `-Heights`, or `-Manual` +selection also runs a diagnostic campaign. In a direct PowerShell invocation, +supply arrays normally: + +```powershell +.\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' ` + -AllowInputInjection -ClearClipboard -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` + -Widths 120 -Heights 30 +``` + +`-Paths herdr-remote` runs the current client against its disposable local +server with the remote clipboard bridge active. It needs no SSH host. A focused +clipboard-image qualification is: + +```powershell +.\scripts\test_windows_input.ps1 -AllowInputInjection -ClearClipboard ` + -Channels stable -Paths direct,herdr-remote -Modes legacy ` + -Cases clipboard-image,clipboard-mixed -Widths 80 -Heights 24 +``` + +Dead-key acute composition is automatic when the target Terminal thread's +active layout exposes that physical mapping. The runner discovers and injects +the real scan-code chord; it never substitutes pasted or Unicode-packet text. +`-Manual` enables AltGr, IME, and the remaining guided composition cases. The operator must activate the +indicated layout/IME, perform the gesture in the test window, then return to the +controller and press Enter. Finish each prompt within the 90-second lease. +Record the layout you actually selected in your qualification notes; the report +also includes the observed foreground thread layout handle. Do not paste text +for a composition case. The runner does not install/change global layouts. + +You can inspect the full catalogue without Windows or desktop interaction: + +```powershell +pwsh -NoProfile -File scripts/test_windows_input.ps1 -ExePath unused -MatrixOnly +``` + +Every run needs a **new** output directory. By default it is +`.local/windows-input//`. Do not reuse an old report directory. + +## What the implementation measures + +- Native console records, including down/up, repeat, virtual/scan codes, Unicode, + modifier state, and raw payloads for other native record types. +- Legacy VT, modifyOtherKeys level 2, and Kitty disambiguation observer modes. + These are **pane consumer modes**, not names for Herdr's outer reader. +- Physical-style Enter/Shift+Enter and other modifier chords, navigation and word + movement chords, control keys, and editing keys. The oracle checks bytes or + records; it does not claim that a particular editor implements word selection + correctly merely because Ctrl+Shift+Right reached it. +- Actual Windows clipboard paste via Ctrl+V, including LF/CRLF/CR, whitespace, + BMP Unicode/combining characters, and escape-looking text. + A host binding or multiline-paste confirmation dialog can intercept the gesture; + the runner does not dismiss unexpected dialogs or rebind Terminal shortcuts. +- Image-only Ctrl+V through the remote clipboard bridge, plus a mixed image/text + clipboard check that proves Windows Terminal and Herdr preserve the text paste. + The report records `paste_origin`, classified from the client's own mapper trace + (`terminal-paste`, `empty-paste`, `key-event`, or `none`). A staged image only + passes when the origin is a positively observed `empty-paste`: a paste the + terminal issued for clipboard text fails, and a missing or inconclusive trace is + `inconclusive` rather than a qualification (#4314). +- A full case pass at an observed 120×30 host size; the full diagnostic campaign also runs keyboard/paste sentinels at + **80, 119, 120, 121, 132, 160, 240 columns**, at 24 and 50 rows; then return to + 80 columns. This exercises narrow→wide→narrow resizing of the actual outer + window. Both outer and pane dimensions are captured. Herdr chrome means pane + width is not the same as outer width. An unreachable size is not a pass. +- Automatic active-layout dead-key acute input, plus guided AltGr, extended + accent, and IME commits, with exact expected committed text. +- Ordered typing, mouse-motion reports, and bracketed paste in one capture, plus + click/wheel reporting before and after a real focus cycle, the same mouse + sentinel after resize, and legacy→modifyOtherKeys→Kitty→modifyOtherKeys→legacy + transitions without restart. + +The focus/resize mouse check runs in Windows Terminal. It guards Herdr's recovery +sequence but does not certify the Tabby/Alacritty host-specific report in #4284. +The catalogue also lists explicit **qualification gaps**: mouse drag and +right-edge coordinate mapping; visual reflow/wrapping; native held-key repeat; +lock/keypad combinations; dead-key cancellation; IME cancellation; capture/config +reload and attach cycles; injected setup/recovery faults; supplementary-plane and +confirmation-triggering burst paste; non-image file clipboard integrations; and +positive host-scrollback evidence for native PageUp/PageDown. These are recorded +`not_run` or `inconclusive`, not fabricated successes. They need +separate fixtures/oracles before becoming automated assertions. The catalogue is +broad; the automated run does **not cover every row**. + +Injected PageUp/PageDown remains `inconclusive` through Herdr. On Windows +Terminal 1.24, both `SendInput` and `keybd_event` produced zeroed non-key records +even in the direct-host baseline, so absence of pane input cannot prove that +Herdr consumed the key. Conclusive qualification requires literal physical input +plus a positive pane scroll-offset change; that guided check is not automated yet. + +## Evidence and verdicts + +The retained directory contains: + +- `matrix.json`: all cases, profiles, and declared byte/record expectations; +- `observations.json`: raw captures, per-case identity, measured dimensions, + readiness/focus flags, errors, and cleanup results; +- `report.json`: interpreted verdicts and counts; +- per-window plans, nonce-bound observer acknowledgements, initial/final console + modes, and bootstrap/probe error records. + +The console ends with a capability matrix derived only from that run's captured +observations. The default pairs Stable/Win32 with Preview/Kitty; diagnostic +campaigns retain the direct-host Plain VT column. Filtered, unavailable, or +operator-assisted cases remain `NOT TESTED` or `MANUAL`; the full evidence and +reasons remain in `report.json`. +The Herdr column is labelled Win32 only after the current binary's selected +reader decodes a real nonce-owned Win32 serialized record. Configuration defaults +alone do not select the label; missing runtime evidence prints `UNKNOWN`. + +Direct-host and through-Herdr observations are labelled separately. A direct-host +failure is **not automatically a Herdr bug**. Each row labels its failure scope as +`direct_host` or `through_herdr_not_yet_attributed`. For example, a terminal may +intercept Alt+Enter or use a different control-key encoding. + +One explicit known-gap rule labels nonce-bound, complete **WT 1.24/1.25 +direct-host mOK captures** of Shift/Ctrl/Ctrl+Shift+Enter or Shift+Tab as +`unsupported` only when they contain the exact legacy bytes. It does not exempt +other tests or versions, an empty/malformed capture, Kitty, or any through-Herdr +failure. Through-Herdr still must preserve the requested gesture. +Unsupported remains incomplete coverage, not green. Review captures before +adding other capability exceptions; never bless lost Shift information in Herdr +just to make a report green. + +Comparisons consume the **complete captured sequence**, including a quiet interval +to catch trailing duplicates/releases. Paste must have one intact bracketed +wrapper and the complete payload. CR/LF variants are compared as logical newlines; +this is not proof of byte-exact line-ending policy. Native keys require their +non-modifier down/up pair, modifier state, and repeat count. + +Exit codes: + +- `0`: all observed assertions passed and no recorded coverage gaps/errors; +- `1`: an assertion, harness, or cleanup failed; +- `2`: selected coverage is incomplete (for example, a missing host or + unavailable geometry). The full diagnostic campaign also selects unimplemented + catalogue rows. + +An empty or missing report never means success. None of these statuses certifies +an entire Windows host. + +## Cleanup and native handoff + +Every window has a fresh nonce, named session, isolated config root, known +PowerShell pane shell, and observer acknowledgement. Inherited Herdr socket, +backend, session, and SSH variables are cleared in the actual launched process, +not just in the controller. Setup APIs only create/focus the observer pane. + +Cleanup targets the named session, retained child-process handles, and the exact +nonce-bearing Terminal window. There is no process-name-wide kill or sweep of +newly appeared OpenConsole processes. Artifacts are retained. Forced cleanup and +console-mode mismatches are reported, not hidden behind a passing key test. + +For release-risk input changes, qualify Stable and Preview, real focus-loss/F12 +interruption, partial startup, clipboard changes, cleanup, and measured width +boundaries. Use old and new Herdr binaries in **separate runs** to establish that +a known regression is caught. Do not overwrite installations or reuse an earlier +server. Portable unit tests validate the catalogue and verdict logic; they do not +replace native Windows evidence. + +The existing `windows_conpty_enhanced_input_probe.ps1` remains the downstream API +control. Existing Rust keyboard/Windows-translator tests remain the deterministic +parser controls. This runner complements them rather than cross-producting their +fixtures or pretending they establish host behavior. diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py new file mode 100644 index 00000000..f63e700e --- /dev/null +++ b/scripts/windows_input/report.py @@ -0,0 +1,579 @@ +"""Portable case catalogue and strict evidence verdicts for the local Windows gauntlet. + +This is not a host simulator. A report cannot turn absent Windows evidence into a pass. +""" +import argparse +import json +import re +from pathlib import Path + +WIDTHS = [80, 119, 120, 121, 132, 160, 240] +HEIGHTS = [24, 50] +MODES = ["native", "legacy", "mok2", "kitty"] + + +def hex_of(text): + return text.encode("utf-8").hex() + + +def catalogue(): + cases = [] + + def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): + mask = (1 if 16 in modifiers else 0) | (2 if 18 in modifiers else 0) | (4 if 17 in modifiers else 0) + unicode = (vk + 32 if 65 <= vk <= 90 else vk if vk in (8, 9, 13, 27) else 0) + if 65 <= vk <= 90 and 16 in modifiers: + unicode = vk + if 17 in modifiers: + unicode = vk - 64 if 65 <= vk <= 90 else 10 if vk == 13 else 127 if vk == 8 else 0 + expected = {"native": {"vk": vk, "modifiers": mask, "unicode": unicode, "modifier_keys": list(modifiers)}} + if text is not None: + expected.update({mode: {"hex": [hex_of(text)]} for mode in MODES[1:]}) + if legacy is not None: + expected["legacy"] = {"hex": [hex_of(legacy)]} + if enhanced is not None: + expected["mok2"] = {"hex": [hex_of(f"\x1b[27;{mask + 1};{enhanced}~")]} + expected["kitty"] = {"hex": [hex_of(f"\x1b[{enhanced};{mask + 1}u")]} + cases.append(dict(id=name, kind="key", chords=[[*modifiers, vk]], expected=expected)) + + key("letter-a", 65, "a") + key("shift-letter", 65, "A", (16,)) + key("enter", 13, "\r") + key("shift-enter", 13, None, (16,), 13) + cases[-1]["expected"]["legacy"] = {"loss": "enter", "hex": ["0d"]} + key("ctrl-enter", 13, None, (17,), 13) + cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]} + key("ctrl-shift-enter", 13, None, (17, 16), 13) + cases[-1]["expected"]["legacy"] = {"loss": "modifier", "hex": ["0a"]} + key("tab", 9, "\t") + key("shift-tab", 9, "\x1b[Z", (16,)) + cases[-1]["expected"]["mok2"] = {"hex": [hex_of("\x1b[27;2;9~")]} + cases[-1]["expected"]["kitty"]["hex"].append(hex_of("\x1b[9;2u")) + key("backspace", 8, "\x7f") + key("ctrl-backspace", 8, None, (17,), 127, "\x08") + key("alt-backspace", 8, None, (18,), 127, "\x1b\x7f") + key("escape", 27, "\x1b") + # Kitty's disambiguation flag encodes Escape distinctly. + cases[-1]["expected"]["kitty"] = {"hex": [hex_of("\x1b[27u")]} + for name, vk, seq, kitty in [("up", 38, "A", 57419), ("down", 40, "B", 57420), ("right", 39, "C", 57418), + ("left", 37, "D", 57417), ("home", 36, "H", 57423), ("end", 35, "F", 57424)]: + for prefix, modifiers, modifier in [("", (), 1), ("shift-", (16,), 2), ("ctrl-", (17,), 5), ("ctrl-shift-", (17, 16), 6)]: + if prefix == "ctrl-shift-" and name in ("up", "down", "home", "end"): + continue + key(prefix + name, vk, "\x1b[" + ("" if modifier == 1 else f"1;{modifier}") + seq, modifiers) + cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}{'' if modifier == 1 else f';{modifier}'}u")) + for name, vk, code, kitty in [("insert", 45, 2, 57425), ("delete", 46, 3, 57426), + ("page-up", 33, 5, 57421), ("page-down", 34, 6, 57422)]: + key(name, vk, f"\x1b[{code}~") + cases[-1]["expected"]["kitty"]["hex"].append(hex_of(f"\x1b[{kitty}u")) + for letter in "acdj lmqsu z".replace(" ", ""): + key("ctrl-" + letter, ord(letter.upper()), None, (17,), ord(letter), chr(ord(letter) - 96)) + key("alt-v", 86, None, (18,), ord("v"), "\x1bv") + for name, text in [ + ("paste-lf", "line 1\nline 2"), + ("paste-crlf", "line 1\r\nline 2\r\n"), + ("paste-cr", "line 1\rline 2"), + ("paste-whitespace", " one\t\n\n two \n"), + ("paste-unicode", "é e\u0301 日本語\nnext"), + ("paste-escape-looking", "literal [200~ and \\x1b[31m\nend"), + ]: + cases.append(dict(id=name, kind="paste", text=text, expected={mode: {"paste": text} for mode in MODES[1:]})) + cases.append(dict(id="clipboard-image", kind="clipboard-image", + expected={mode: {"clipboard_image": True, + "sha256": "4BA8D4FD5AB42544FEEFF22D50E84412502433CC95952FD1DF9A5293588DDBEA"} + for mode in MODES[1:]})) + cases.append(dict(id="clipboard-mixed", kind="clipboard-mixed", text="clipboard text wins", + expected={mode: {"paste": "clipboard text wins"} for mode in MODES[1:]})) + cases.append(dict(id="mouse-interleave", kind="mouse-interleave", text="mouse\npaste", + expected={mode: {"mouse_interleave": True} for mode in MODES[1:]})) + cases.append(dict(id="mouse-focus-refresh", kind="mouse-focus-refresh", + expected={mode: {"mouse_focus_refresh": True} for mode in MODES[1:]})) + transitions = "a\r" + "b\x1b[27;2;13~" + "c\x1b[13;2u" + "d\x1b[27;2;13~" + "e\r" + "f" + cases.append(dict(id="mode-transitions", kind="mode-transitions", + expected={mode: {"hex": [hex_of(transitions)]} for mode in MODES[1:]})) + cases.append(dict(id="dead-acute", kind="layout-key", dead="´", base_vk=69, + expected={mode: {"hex": [hex_of("é")]} for mode in MODES[1:]})) + for name, prompt, text in [ + ("dead-grave", "With US-International active, type grave then e, once; no Enter.", "è"), + ("dead-circumflex", "With US-International active, type circumflex then e, once; no Enter.", "ê"), + ("dead-tilde", "With US-International active, type tilde then n, once; no Enter.", "ñ"), + ("dead-diaeresis", "With US-International active, type diaeresis then u, once; no Enter.", "ü"), + ("dead-space", "With US-International active, type acute then Space, once; no Enter.", "'"), + ("altgr-euro", "With your declared euro-producing AltGr layout, type € using its AltGr chord, once; no Enter.", "€"), + ("ime-commit", "Using your declared IME, compose and commit 日本語 (not paste); do not submit the prompt.", "日本語"), + ]: + cases.append(dict(id=name, kind="manual", prompt=prompt, expected={mode: {"hex": [hex_of(text)]} for mode in MODES[1:]})) + # Explicit qualification tasks, never auto-passed by key/byte checks. + for name, prompt in [ + ("dead-cancel-repeat", "Qualify dead-key cancellation, repeated accent and non-composing next character against the direct-host baseline."), + ("ime-cancel", "Qualify IME cancel and partial composition without committed/duplicate text."), + ("native-repeat-release", "Qualify held-key repeat, release identity, and modifier interleaving using native records."), + ("locks-keypad", "Qualify CapsLock/NumLock, keypad Enter/operators/decimal and restore lock states."), + ("mouse-right-edge", "Click/drag/wheel at a marked pane column above 120; compare actual report coordinates and visual target."), + ("wrap-rendering", "Verify the displayed ruler and wrapped multiline text at both window heights and every observed width."), + ("capture-refresh", "Toggle mouse capture/config reload, refocus, detach/reattach; repeat paste and Shift+Enter."), + ("setup-recovery", "Inject a recoverable setup failure and late VT activation; verify mode restoration and the same input sentinels."), + ("ctrl-v", "Qualify raw Ctrl+V only with the Windows Terminal paste binding explicitly removed."), + ("alt-enter", "Qualify Alt+Enter with the Windows Terminal fullscreen binding explicitly controlled."), + ("ctrl-shift-up", "Qualify Ctrl+Shift+Up with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-down", "Qualify Ctrl+Shift+Down with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-home", "Qualify Ctrl+Shift+Home with the Windows Terminal scroll binding explicitly controlled."), + ("ctrl-shift-end", "Qualify Ctrl+Shift+End with the Windows Terminal scroll binding explicitly controlled."), + ("paste-supplementary", "Qualify supplementary-plane clipboard text, including emoji, against the direct-host baseline."), + ("paste-burst", "Qualify a 200-line clipboard burst with the host multiline-paste warning configured or handled explicitly."), + ]: + cases.append(dict(id=name, kind="qualification", prompt=prompt, expected={})) + legacy = ["letter-a", "shift-letter", "up", "down", "left", "right", "home", "end", "insert", "delete", + "tab", "shift-tab", "paste-lf", "paste-crlf", "paste-cr", "paste-whitespace", "paste-unicode", + "paste-escape-looking", "mouse-interleave", "mouse-focus-refresh", "mode-transitions"] + mok2 = ["letter-a", "shift-enter", "ctrl-enter", "ctrl-shift-enter", "paste-lf", "dead-acute"] + release_plan = [dict(channel="stable", path="herdr", mode="legacy", cases=legacy), + dict(channel="stable", path="herdr", mode="mok2", cases=mok2), + dict(channel="preview", path="herdr", mode="kitty", cases=sorted(set(legacy + mok2)))] + return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases, release_plan=release_plan) + + +def classification_of_client_events(trace_lines): + """Classify how a paste reached the pane from the mapper's client-event trace. + + The pane capture alone cannot tell a paste the terminal issued from a + reaction to a consumed key or an empty bracketed paste (#4314). The mapper + trace records the decoded client events, so the batch that carried the + paste identifies its origin. Returns one of: + "terminal-paste", "empty-paste", "key-event", "none", or None when the + trace is unavailable. Only "terminal-paste" and "empty-paste" are positive + evidence; callers must not treat the others as bridge proof. + """ + if trace_lines is None: + return None + for line in trace_lines: + if not isinstance(line, str): + return None + if 'Paste { text: ""' in line: + return "empty-paste" + if "Paste {" in line: + return "terminal-paste" + for line in trace_lines: + if isinstance(line, str) and 'Key {' in line and "kind: Press" in line: + return "key-event" + return "none" + + +def verdict(case, mode, evidence): + """The full capture, not a matching prefix, is the primary assertion.""" + if evidence.get("status") in {"not_run", "unsupported", "inconclusive"}: + return evidence["status"], evidence.get("reason", "No qualifying observation") + expected = case["expected"].get(mode) + if expected is None: + return "not_run", "No automatic oracle for this case/profile; requires qualification" + if not evidence.get("ready") or not evidence.get("focus_verified") or not evidence.get("complete"): + return "inconclusive", "Missing readiness, focus, or complete capture" + if evidence.get("error"): + return "inconclusive", evidence["error"] + if case["kind"] == "clipboard-image" and evidence.get("path") == "herdr": + return "not_run", "Clipboard image bridging is active only for remote clients" + if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" not in expected: + expected = {"hex": [""]} # Plain page keys intentionally control Herdr's host scrollback. + def geometry(name): + value = evidence.get(name) + return value[:2] if isinstance(value, list) and len(value) >= 2 and all(type(v) is int for v in value[:2]) else None + outer, pane = geometry("outer_geometry"), geometry("pane_geometry") + final_pane, final_outer = geometry("final_pane_geometry"), geometry("final_outer_geometry") + if outer != [evidence.get("width"), evidence.get("height")]: + return "inconclusive", "Requested geometry was not observed" + if pane is None or min(pane) <= 0: + return "inconclusive", "Missing actual pane dimensions" + if final_pane != pane: + return "inconclusive", "Pane geometry changed during capture" + if final_outer != outer: + return "inconclusive", "Outer geometry changed during capture" + if evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") and "vk" in expected: + records = evidence.get("records") + if not isinstance(records, list) or any(not isinstance(record, list) or len(record) != 7 + or not all(type(field) is int for field in record) for record in records): + return "inconclusive", "Malformed native record" + return (("inconclusive", "No positive host-scrollback evidence for the consumed page key") + if all(record[0] in (4, 16) for record in records) + else ("fail", "Plain page key unexpectedly reached the pane")) + if (evidence.get("path") == "herdr" and case["id"] in ("page-up", "page-down") + and evidence.get("hex") == ""): + return "inconclusive", "No positive host-scrollback evidence for the consumed page key" + if "vk" in expected: + records = evidence.get("records") + scans = evidence.get("scans", []) + chord = case["chords"][0] + if not isinstance(records, list): + return "inconclusive", "Malformed native record" + if not isinstance(scans, list) or len(scans) != len(chord) or not all(type(scan) is int for scan in scans): + return "inconclusive", "Missing injected scan-code evidence" + expected_scans = dict(zip(chord, scans)) + aliases = {160: 16, 161: 16, 162: 17, 163: 17, 164: 18, 165: 18} + held = set() + keys = [] + for record in records: + if not isinstance(record, list) or len(record) != 7 or not all(type(field) is int for field in record): + return "inconclusive", "Malformed native record" + if record[0] in (4, 16): # Resize/focus notifications carry no typed text. + continue + if record[0] != 1: + return "fail", "Unexpected non-key native input" + vk = aliases.get(record[3], record[3]) + if vk not in (16, 17, 18): + keys.append(record) + continue + if vk not in expected["modifier_keys"] or record[5] != 0 or record[2] != 1 or record[4] != expected_scans[vk]: + return "fail", "Unexpected/corrupted modifier record" + if record[1] == 1 and vk not in held: + held.add(vk) + elif record[1] == 0 and vk in held: + held.remove(vk) + else: + return "fail", "Unbalanced modifier sequence" + if held: + return "fail", "Modifier release missing" + if len(keys) != 2 or [r[1] for r in keys] != [1, 0]: + return "fail", "Expected exactly one non-modifier down/up pair" + for record in keys: + control = record[6] + modifiers = bool(control & 16) + 2 * bool(control & 3) + 4 * bool(control & 12) + if (record[3] != expected["vk"] or modifiers != expected["modifiers"] or record[2] != 1 + or record[5] != expected["unicode"] or record[4] != expected_scans[expected["vk"]]): + return "fail", "Native identity/modifiers/repeat differ" + return "pass", "Native down/up pair matches" + try: + raw = bytes.fromhex(evidence["hex"]) + except (KeyError, ValueError, TypeError): + return "inconclusive", "Missing or malformed raw bytes" + if expected.get("clipboard_image"): + origin = evidence.get("paste_origin") + if evidence.get("path") == "direct": + if raw != b"\x1b[200~\x1b[201~": + return "fail", "Terminal did not emit an empty bracketed paste for image-only clipboard" + if origin not in (None, "empty-paste"): + return "fail", f"Direct empty paste was decoded as {origin}, not an empty paste" + return "pass", "Terminal emitted an empty bracketed paste for image-only clipboard" + if evidence.get("path") != "herdr-remote": + return "not_run", "Clipboard image bridge requires the remote-client gauntlet path" + if not raw.startswith(b"\x1b[200~") or not raw.endswith(b"\x1b[201~"): + return "fail", "Remote clipboard image did not reach the pane as one paste" + try: + path = raw[6:-6].decode("utf-8") + except UnicodeDecodeError: + return "fail", "Staged clipboard image path is not UTF-8" + valid_path = re.fullmatch(r"[A-Za-z]:\\.*\\herdr-clipboard-images-[^\\]+\\[^\\]+\.png", path) + if not valid_path: + return "fail", "Pane did not receive a staged clipboard PNG path" + # A staged image must come from the bridge reacting to an empty paste, not + # from a paste the terminal issued for text on the clipboard (#4314). + if origin == "terminal-paste": + return "fail", "Terminal issued the paste; the remote image bridge did not react to the empty paste" + if origin != "empty-paste": + # Without the mapper trace there is no evidence the bridge reacted to + # an empty paste; a staged path alone cannot qualify the bridge. + return "inconclusive", "Missing client trace evidence of an empty-paste bridge reaction" + return (("pass", "Exact clipboard PNG was staged and its path reached the pane") + if evidence.get("staged_image_sha256") == expected["sha256"] else + ("fail", "Staged clipboard image contents differ from the fixture")) + if expected.get("mouse_interleave"): + motion = rb"(?:\x1b\[<35;\d+;\d+M)+" + newline = rb"(?:\r\n|\r|\n)" + pattern = b"a" + motion + rb"\x1b\[200~mouse" + newline + rb"paste\x1b\[201~" + motion + b"b" + return ("pass", "Typing, mouse motion and paste remained ordered") if re.fullmatch(pattern, raw) else ("fail", "Mouse interleave order or payload differs") + if expected.get("mouse_focus_refresh"): + coords = rb"\d+;\d+" + gesture = (rb"(?:\x1b\[<35;" + coords + rb"M)*" + + rb"\x1b\[<0;" + coords + rb"M" + + rb"\x1b\[<0;" + coords + rb"m" + + rb"\x1b\[<64;" + coords + rb"M") + pattern = b"a" + gesture + b"b" + b"c" + gesture + b"d" + return ("pass", "Click/wheel reporting survived focus loss and regain") if re.fullmatch(pattern, raw) else ("fail", "Mouse reporting failed before or after focus regain") + if case["id"] == "mode-transitions" and evidence.get("path") == "direct": + legacy_only = hex_of("a\rb\rc\rd\re\rf") + kitty_only = hex_of("a\rb\rc\x1b[13;2u" + "d\re\rf") + if raw.hex() in {legacy_only, kitty_only}: + return "unsupported", "Direct Windows Terminal ignored modifyOtherKeys during the transition chain" + return "fail", "Unexpected direct-host runtime transition bytes" + if "loss" in expected: + return ("unsupported", "Plain VT cannot preserve modified Enter") if raw.hex() in expected["hex"] else ("fail", "Unexpected plain-VT modified Enter result") + if "hex" in expected: + return ("pass", "Exact bytes match") if raw.hex() in expected["hex"] else ("fail", "Bytes differ (including any duplicates/trailing input)") + if not raw.startswith(b"\x1b[200~") or not raw.endswith(b"\x1b[201~"): + return "fail", "Missing bracketed-paste envelope; newlines may be key events" + try: + actual = raw[6:-6].decode("utf-8") + except UnicodeDecodeError: + return "fail", "Paste is not intact UTF-8" + # Line-ending conversion is expected on Windows; all other payload bytes matter. + normalize = lambda text: text.replace("\r\n", "\n").replace("\r", "\n") + return ("pass", "One complete paste matches") if normalize(actual) == normalize(expected["paste"]) else ("fail", "Paste payload differs") + + +def known_host_gap(observation, case, terminal_version): + """A narrowly observed host limitation, never a blanket version exemption.""" + legacy = case["expected"].get("legacy", {}).get("hex", []) + mok2 = case["expected"].get("mok2", {}).get("hex", []) + return (observation.get("path") == "direct" + and observation.get("mode") == "mok2" + and case["id"] in {"shift-enter", "ctrl-enter", "ctrl-shift-enter", "shift-tab"} + and re.match(r"^1\.(?:24|25)\.", str(terminal_version)) and set(legacy) != set(mok2) + and observation.get("hex") in legacy) + + +def channel_identity_errors(hosts): + """Compare both launcher identities and the processes actually activated.""" + identities = {} + for host in hosts: + values = identities.setdefault(host.get("channel"), set()) + for field, kind in (("launcher_identity", "file"), ("installation_identity", "installation")): + if host.get(field): + values.add((kind, host[field])) + for run in host.get("runs", []): + for field, kind in (("image_identity", "file"), ("installation_identity", "installation"), ("process_identity", "process")): + if run.get(field): + values.add((kind, run[field])) + return ["Stable and Preview share a Terminal executable, installation, or process identity"] if identities.get("stable", set()) & identities.get("preview", set()) else [] + + +def summarize(document): + matrix = catalogue() + cases = {case["id"]: case for case in matrix["cases"]} + rows = [] + seen = set() + captures = set() + for observation in document.get("observations", []): + identity = tuple(observation.get(k) for k in ("host", "path", "mode", "phase", "width", "height", "case")) + if identity in seen: + raise ValueError(f"Duplicate observation identity: {identity}") + seen.add(identity) + case = cases[observation["case"]] + paste_origin = classification_of_client_events(observation.get("client_events")) + if paste_origin is not None: + observation = {**observation, "paste_origin": paste_origin} + status, reason = verdict(case, observation["mode"], observation) + scope = "direct_host" if observation.get("path") == "direct" else "through_herdr_not_yet_attributed" + if status in ("pass", "fail"): + bound = [run for host in document.get("hosts", []) if host.get("channel") == observation.get("host") + for run in host.get("runs", []) + if run.get("nonce") == observation.get("nonce") and run.get("path") == observation.get("path") + and run.get("mode") == observation.get("mode") and run.get("pid", 0) > 0 and run.get("hwnd", 0) != 0 + and run.get("elevated") is False and run.get("image_identity") and run.get("installation_identity")] + capture = observation.get("capture_id") + if len(bound) != 1 or document.get("controller_elevated") is not False or not capture or capture in captures: + status, reason = "inconclusive", "Missing non-elevated owned-run binding or fresh capture identity" + else: + captures.add(capture) + if status == "fail" and known_host_gap(observation, case, bound[0].get("terminal_version")): + status, reason = "unsupported", "Direct host ignored mOK and emitted the case's legacy bytes" + rows.append({**observation, "status": status, "reason": reason, "failure_scope": scope}) + counts = {status: sum(r["status"] == status for r in rows) for status in ("pass", "fail", "not_run", "unsupported", "inconclusive")} + # No run may claim all-green just because it produced zero/missing observations. + planned = set() + geometries = [(120, 30, True)] + [(w, h, False) for h in document.get("heights", HEIGHTS) for w in document.get("widths", WIDTHS)] + [(80, 30, False)] + run_specs = document.get("run_specs") or [dict(channel=host, path=path, mode=mode, cases=document.get("cases") or list(cases)) + for host in document.get("channels") or ("stable", "preview") + for path in document.get("paths") or ("direct", "herdr") + for mode in document.get("modes", MODES)] + for spec in run_specs: + for phase, (width, height, full) in enumerate(geometries, 1): + for case_id in spec["cases"] if full else (case_id for case_id in ("letter-a", "shift-enter", "paste-lf", "mouse-focus-refresh") if case_id in spec["cases"]): + planned.add((spec["channel"], spec["path"], spec["mode"], phase, width, height, case_id)) + missing = planned - seen + if seen - planned: + raise ValueError("Observations outside the declared run matrix") + hosts = document.get("hosts", []) + errors = list(document.get("errors", [])) + channel_identity_errors(hosts) + expected_hosts = set(document.get("channels") or ("stable", "preview")) + complete = (bool(rows) and not missing and {h.get("channel") for h in hosts} == expected_hosts + and all(h.get("runs") for h in hosts) + and not errors and not document.get("cleanup_errors") + and all(r["status"] == "pass" for r in rows) + and (document.get("campaign") != "release" or + herdr_protocol_proven({"hosts": hosts, "observations": rows}, {"stable"}))) + return {**document, "errors": errors, "observations": rows, "counts": counts, "coverage_missing": len(missing), "observed_checks_passed": complete, + "native_qualification": "Required; this report is not a full Windows support certificate"} + + +def qualification_matrix(result): + """Collapse real observations into the user-facing capability summary.""" + rows = result.get("observations", []) + release = result.get("campaign") == "release" + required_channels = set(result.get("channels") or ("stable", "preview")) + manual_cases = {case["id"] for case in catalogue()["cases"] if case["kind"] == "manual"} + groups = [ + ("Printable keys", {"letter-a", "shift-letter"}, None), + ("Shift+Enter", {"shift-enter"}, None), + ("Ctrl+Enter", {"ctrl-enter"}, None), + ("Ctrl+Shift+Enter", {"ctrl-shift-enter"}, None), + ("Navigation/editing", {"up", "down", "left", "right", "home", "end", "insert", "delete", "tab", "shift-tab", "page-up", "page-down"}, None), + ("Multiline paste", {"paste-lf"}, None), + ("CR/LF/CRLF paste", {"paste-lf", "paste-crlf", "paste-cr"}, None), + ("Unicode/whitespace paste", {"paste-unicode", "paste-whitespace"}, None), + ("Paste framing/ordering", {case["id"] for case in catalogue()["cases"] if case["kind"] == "paste"}, None), + ("Remote clipboard image", {"clipboard-image", "clipboard-mixed"}, None), + ("Resize 120 -> 80", {"letter-a", "shift-enter", "paste-lf"}, 80), + ("Mouse while typing/pasting", {"mouse-interleave"}, None), + ("Mouse after focus regain", {"mouse-focus-refresh"}, None), + ("Mouse after resize", {"mouse-focus-refresh"}, 80), + ("Dead-key composition", {"dead-acute"}, None), + ("AltGr", {"altgr-euro"}, None), + ("IME composition", {"ime-commit"}, None), + ("Runtime mode transitions", {"mode-transitions"}, None), + ] + + if release: + groups = [(name, case_ids - {"page-up", "page-down"} if name == "Navigation/editing" else case_ids, + None if name == "Resize 120 -> 80" else width) + for name, case_ids, width in groups if name != "Remote clipboard image"] + groups.insert(5, ("PageUp/PageDown scroll", set(), None)) + release_specs = (result.get("run_specs") or catalogue()["release_plan"]) if release else [] + + def cell(case_ids, width, path, modes, channels=required_channels, required_widths=None, failure_modes=None): + matched = [row for row in rows if row.get("case") in case_ids and row.get("path") == path + and row.get("mode") in modes and row.get("host") in channels + and (width is None or row.get("width") == width)] + statuses = {row.get("status") for row in matched} + if failure_modes: + selected_rows = [row for row in rows if row.get("case") in case_ids and row.get("path") == path + and row.get("mode") in failure_modes and row.get("host") in channels + and (width is None or row.get("width") == width)] + statuses.update(row.get("status") for row in selected_rows if row.get("status") != "pass") + if "fail" in statuses: + return "FAIL" + if case_ids <= manual_cases and not any(status == "pass" for status in statuses): + return "MANUAL" + if release and statuses - {"pass"}: + return "INCONCLUSIVE" if "inconclusive" in statuses else "UNSUPPORTED" if "unsupported" in statuses else "PARTIAL" if "pass" in statuses else "NOT TESTED" + if {row.get("case") for row in matched} != case_ids: + return "PARTIAL" if "pass" in statuses else "INCONCLUSIVE" if "inconclusive" in statuses else "NOT TESTED" + expected_pairs = {(channel, case_id) for channel in channels for case_id in case_ids} + if channels and {(row.get("host"), row.get("case")) for row in matched} != expected_pairs: + return "PARTIAL" + if failure_modes: + selected_pairs = {(spec["mode"], case_id) for spec in release_specs if spec["channel"] in channels + and spec["path"] == path and spec["mode"] in failure_modes for case_id in case_ids + if case_id in spec["cases"]} + if selected_pairs - {(row.get("mode"), row.get("case")) for row in selected_rows}: + return "PARTIAL" + if required_widths and not { + (channel, case_id, required_width) for channel in channels for case_id in case_ids + for required_width in required_widths} <= { + (row.get("host"), row.get("case"), row.get("width")) for row in matched}: + return "PARTIAL" + if case_ids == {"mode-transitions"} and path == "direct" and statuses <= {"unsupported", "inconclusive"}: + return "X - mOK ignored" + if path == "direct" and modes == {"legacy"} and statuses - {"not_run"} == {"unsupported"}: + if {(row.get("host"), row.get("case")) for row in matched if row.get("status") == "unsupported"} != expected_pairs: + return "PARTIAL" + return "X - becomes Enter" if case_ids == {"shift-enter"} else "X - loses modifier" + def case_passed(case_id, channel=None): + statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id + and (channel is None or row.get("host") == channel)} + return "pass" in statuses_for_case or (width == 80 and path == "direct" and modes == {"legacy"} + and case_id == "shift-enter" and statuses_for_case - {"not_run"} == {"unsupported"}) + per_case_passed = all(case_passed(case_id) for case_id in case_ids) + if "not_run" in statuses and all(case_passed(case_id, channel) for channel in channels for case_id in case_ids): + return "PARTIAL" + allowed = {"pass", "inconclusive"} + if width == 80 and path == "direct" and modes == {"legacy"}: + allowed.add("unsupported") + if per_case_passed and statuses <= allowed: + return "PASS**" if "inconclusive" in statuses else "PASS" + if "inconclusive" in statuses: + return "INCONCLUSIVE" + if "unsupported" in statuses: + return "UNSUPPORTED" + return "NOT TESTED" + + table = [] + for name, case_ids, width in groups: + if release and name == "PageUp/PageDown scroll": + table.append((name, "MANUAL", "MANUAL")) + continue + herdr_modes = {"legacy"} if name in {"Mouse after resize", "Runtime mode transitions"} else {"mok2"} if "Enter" in name or name in {"Resize 120 -> 80", "Dead-key composition", "AltGr", "IME composition"} else {"legacy"} + herdr_path = "herdr-remote" if name == "Remote clipboard image" else "herdr" + if release: + required_widths = {120, 80} if name == "Resize 120 -> 80" else None + table.append((name, cell(case_ids, width, "herdr", herdr_modes, {"stable"}, required_widths, {"legacy", "mok2"}), + cell(case_ids, width, "herdr", {"kitty"}, {"preview"}, required_widths))) + else: + table.append((name, cell(case_ids, width, herdr_path, herdr_modes), + cell(case_ids, width, "direct", {"legacy"}), cell(case_ids, width, "direct", {"kitty"}))) + return table + + +def herdr_protocol_proven(result, channels=None): + runs = {(host.get("channel"), run.get("path"), run.get("mode"), run.get("nonce")) + for host in result.get("hosts", []) for run in host.get("runs", []) + if run.get("path") in {"herdr", "herdr-remote"} and run.get("nonce") + and (channels is None or host.get("channel") in channels)} + proven = {(row.get("host"), row.get("path"), row.get("mode"), row.get("nonce")) + for row in result.get("observations", []) + if row.get("path") in {"herdr", "herdr-remote"} and row.get("input_reader") == "windows-console" + and row.get("input_transport") == "win32-serialized" and row.get("nonce")} + return bool(runs) and runs <= proven + + +def herdr_protocol_label(result, channels=None): + return "Win32 (Herdr)*" if herdr_protocol_proven(result, channels) else "Herdr default (UNKNOWN)*" + + +def print_qualification_matrix(result): + table = ([("Thing", "Stable WT / " + herdr_protocol_label(result, {"stable"}), "Preview WT / Kitty")] + if result.get("campaign") == "release" else + [("Thing", herdr_protocol_label(result), "Plain VT", "Kitty")]) + qualification_matrix(result) + widths = [max(len(str(row[column])) for row in table) for column in range(len(table[0]))] + line = lambda row: " | ".join(str(value).ljust(widths[index]) for index, value in enumerate(row)) + print("\nWindows input qualification results") + print(line(table[0])) + print("-+-".join("-" * width for width in widths)) + for row in table[1:]: + print(line(row)) + print("* Herdr protocol label comes from runtime evidence; UNKNOWN is never treated as Win32.") + if result.get("campaign") == "release": + print("MANUAL: physically verify PageUp/PageDown scrolling, AltGr, and IME before release.") + else: + print("** At least one capable host passed; host capability gaps remain visible in report.json.") + print("MANUAL requires an operator-assisted -Manual run; no automated result is claimed.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["matrix", "report"]) + parser.add_argument("--input", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = catalogue() if args.command == "matrix" else summarize(json.loads(args.input.read_text(encoding="utf-8-sig"))) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if args.command == "report": + counts = result["counts"] + through_failures = sum(row["status"] == "fail" and row.get("path") != "direct" for row in result["observations"]) + direct_failures = sum(row["status"] == "fail" and row.get("path") == "direct" for row in result["observations"]) + print(f"Observed: {counts['pass']} pass, {counts['fail']} fail, {counts['unsupported']} unsupported, " + f"{counts['inconclusive']} inconclusive, {counts['not_run']} not run; " + f"{result['coverage_missing']} planned rows missing") + if through_failures: + print(f"Assessment: {through_failures} through-Herdr failures need attribution; inspect report.json before treating them as product regressions") + elif result.get("errors") or result.get("cleanup_errors"): + print("Assessment: harness or cleanup failed; this run does not qualify input behavior") + elif result.get("campaign") == "release" and not herdr_protocol_proven(result, {"stable"}): + print("Assessment: Stable Win32 runtime evidence missing; the release matrix is incomplete") + elif direct_failures: + print(f"Assessment: {direct_failures} direct-host differences observed; these are not automatically Herdr bugs") + elif counts["inconclusive"] or counts["not_run"] or counts["unsupported"] or result["coverage_missing"]: + print("Assessment: observed assertions passed, but host limitations or qualification gaps remain") + else: + print("Assessment: all planned observed assertions passed") + if result.get("errors"): + print("Run errors: " + " | ".join(result["errors"])) + if result.get("cleanup_errors"): + print("Cleanup errors: " + " | ".join(result["cleanup_errors"])) + failures = [row for row in result["observations"] if row["status"] == "fail"] + for row in failures: + print(f"FAIL: {row['host']}/{row['path']}/{row['mode']} {row['case']} at {row['width']}x{row['height']}: {row['reason']}") + print_qualification_matrix(result) + return 1 if result["counts"]["fail"] or result.get("errors") or result.get("cleanup_errors") else 2 if not result["observed_checks_passed"] else 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/herdr/SKILL.md b/skills/herdr/SKILL.md index bcb22ba8..fe2ccb6f 100644 --- a/skills/herdr/SKILL.md +++ b/skills/herdr/SKILL.md @@ -102,7 +102,7 @@ The selector must be an enabled saved profile ID or a unique, case-sensitive lab Both installations must support machine API forwarding, and the remote server must already be running and API-compatible. Forwarding never installs, starts, or restarts a server and never falls back to Local. Local configuration, session management, installation commands, and interactive attachment are not forwarded. Remote worktree paths must be absolute, `~`, or start with `~/`; plugin link paths must be absolute. A connection failure does not prove a mutation was not applied: inspect remote state before retrying. -`herdr machine list` lists saved connection profiles, not a cross-machine pane inventory; add `--json` for scripts. Only add, remove, enable, or disable profiles when the user asks. Removing a profile disconnects the client but does not stop remote sessions. Adding a machine uses the remote default session unless `--remote-session` is explicitly supplied. Setup asks before stopping an incompatible server and defaults to No; do not approve replacement without the user's consent. Experimental handoff is not part of `machine add`. +`herdr machine list` lists saved connection profiles, not a cross-machine pane inventory; add `--json` for scripts. Only add, remove, enable, or disable profiles when the user asks. Removing a profile disconnects the client but does not stop remote sessions. Interactive `machine add` discovers running remote sessions and may ask the user to pick one; non-interactive setup uses the remote default session unless `--remote-session` is explicitly supplied. Use `herdr machine status [] --json` to diagnose a failing machine. `herdr machine reconnect` needs the user to complete SSH authentication in their terminal; do not run it for them. Setup asks before stopping an incompatible server and defaults to No; do not approve replacement without the user's consent. Experimental handoff is not part of `machine add`. ## Start and coordinate an agent diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 04e6cf63..d6a68e45 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -4,8 +4,10 @@ use serde::{Deserialize, Serialize}; const MAX_SESSION_ID_LEN: usize = 512; const MAX_SESSION_PATH_LEN: usize = 4096; +const MAX_RESUME_ARGS: usize = 64; +const MAX_RESUME_ARGV_BYTES: usize = 8192; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSessionRef { pub kind: AgentSessionRefKind, pub value: String, @@ -25,6 +27,67 @@ pub struct AgentResumePlan { pub dedupe_key: String, } +/// A resume command reported by the agent itself, run in the restored pane. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportedAgentResume { + pub source: String, + pub agent: String, + pub argv: Vec, +} + +impl ReportedAgentResume { + /// The same command can name different sessions in different directories, + /// for example `agent --continue`, so the directory is part of its identity. + pub fn plan(&self, cwd: &Path) -> AgentResumePlan { + AgentResumePlan { + agent: self.agent.clone(), + argv: self.argv.clone(), + dedupe_key: format!( + "{}\u{0}{}\u{0}{}\u{0}argv\u{0}{}", + self.source, + self.agent, + cwd.display(), + self.argv.join("\u{0}") + ), + } + } +} + +/// Restore types the command into the pane's shell, so the executable must be a +/// bare command name: shells disagree on how to invoke a quoted path. +pub fn validate_resume_argv(argv: &[String]) -> Result<(), String> { + let Some(command) = argv.first() else { + return Err("resume_argv must not be empty".into()); + }; + if argv.len() > MAX_RESUME_ARGS { + return Err(format!( + "resume_argv allows at most {MAX_RESUME_ARGS} arguments" + )); + } + if argv.iter().map(String::len).sum::() > MAX_RESUME_ARGV_BYTES { + return Err(format!( + "resume_argv allows at most {MAX_RESUME_ARGV_BYTES} bytes" + )); + } + if argv.iter().any(|arg| arg.chars().any(char::is_control)) { + return Err("resume_argv must not contain control characters".into()); + } + // Restore quotes arguments POSIX-style, which PowerShell reads differently + // only when an argument itself contains an apostrophe. + if argv.iter().any(|arg| arg.contains('\'')) { + return Err("resume_argv must not contain apostrophes".into()); + } + let plain_command = !command.is_empty() + && !command.starts_with('-') + && command + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')); + if !plain_command { + return Err("resume_argv must start with a plain command name, not a path".into()); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PersistedAgentSession { pub source: String, @@ -309,6 +372,38 @@ mod tests { .to_string() } + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|part| part.to_string()).collect() + } + + #[test] + fn reported_resume_argv_requires_a_plain_command_name() { + assert!(validate_resume_argv(&argv(&[ + "prime-agent", + "--resume", + "01a0de21", + "--model", + "gpt 6" + ])) + .is_ok()); + assert!(validate_resume_argv(&argv(&["cursor-agent.cmd", "--resume", "id"])).is_ok()); + + for invalid in [ + argv(&[]), + argv(&[""]), + argv(&["/usr/bin/prime-agent", "--resume", "id"]), + argv(&["C:\\Program Files\\Prime\\prime.exe"]), + argv(&["prime agent"]), + argv(&["-prime"]), + argv(&["prime-agent", "bad\nline"]), + argv(&["prime-agent", "--name", "can's session"]), + vec!["prime-agent".to_string(); MAX_RESUME_ARGS + 1], + argv(&["prime-agent", &"x".repeat(MAX_RESUME_ARGV_BYTES)]), + ] { + assert!(validate_resume_argv(&invalid).is_err(), "{invalid:?}"); + } + } + #[test] fn native_state_reservation_excludes_full_lifecycle_sources() { assert!(is_reserved_native_state_source("herdr:claude", "claude")); diff --git a/src/api/client.rs b/src/api/client.rs index 45a7b104..7265bd7c 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -1,7 +1,7 @@ use std::fmt; -use std::io::{self, BufRead, BufReader, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use std::path::PathBuf; -use std::time::Duration; +use std::time::{Duration, Instant}; use interprocess::local_socket::traits::Stream as _; use serde::de::DeserializeOwned; @@ -75,10 +75,37 @@ impl ApiClient { } pub fn status(&self) -> Result { - let response = self.request(Request { + self.read_status(None) + } + + pub(crate) fn status_with_timeout( + &self, + timeout: Duration, + ) -> Result { + self.read_status(Some(timeout)) + } + + fn read_status( + &self, + timeout: Option, + ) -> Result { + let request = Request { id: "api-client:status".into(), method: Method::Ping(PingParams::default()), - })?; + }; + let response = match timeout { + Some(timeout) => { + let mut stream = self.connect()?; + write_request(&mut stream, &request)?; + crate::ipc::set_local_stream_polling(&mut stream, true)?; + let mut reader = BufReader::new(DeadlineReader { + stream: &mut stream, + deadline: Instant::now() + timeout, + }); + parse_response_value(read_json_line(&mut reader)?)? + } + None => self.request(request)?, + }; match response.result { ResponseResult::Pong { version, @@ -164,9 +191,37 @@ fn write_request(stream: &mut LocalStream, request: &Request) -> Result<(), ApiC Ok(()) } -fn read_json_line( - reader: &mut BufReader, -) -> Result { +struct DeadlineReader<'a> { + stream: &'a mut LocalStream, + deadline: Instant, +} + +impl Read for DeadlineReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if buffer.is_empty() { + return Ok(0); + } + loop { + if Instant::now() >= self.deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "server status probe timed out", + )); + } + // Windows named pipes have no read timeout; peek-before-read keeps + // both idle and partial responses subject to the same deadline. + match crate::ipc::poll_local_stream_read_count(self.stream, buffer)? { + crate::ipc::LocalStreamReadCount::Data(count) => return Ok(count), + crate::ipc::LocalStreamReadCount::Closed => return Ok(0), + crate::ipc::LocalStreamReadCount::Pending => { + std::thread::sleep(Duration::from_millis(2)) + } + } + } + } +} + +fn read_json_line(reader: &mut impl BufRead) -> Result { let mut line = String::new(); let read = reader.read_line(&mut line)?; if read == 0 || line.trim().is_empty() { @@ -201,6 +256,34 @@ mod tests { assert!(client.socket_path().ends_with("sessions/work/herdr.sock")); } + #[test] + fn status_timeout_closes_a_stalled_probe() { + use interprocess::local_socket::traits::Listener as _; + let path = + std::env::temp_dir().join(format!("herdr-status-timeout-{}.sock", std::process::id())); + let listener = crate::ipc::bind_private_local_listener(&path).unwrap(); + let server = std::thread::spawn(move || { + let stream = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap()["method"], + "ping" + ); + std::thread::sleep(Duration::from_millis(300)); + }); + let client = ApiClient::for_target(ConnectionTarget::SocketPath(path.clone())); + let error = client + .status_with_timeout(Duration::from_millis(100)) + .unwrap_err(); + assert!( + matches!(error, ApiClientError::Io(error) if matches!(error.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock)) + ); + server.join().unwrap(); + std::fs::remove_file(path).unwrap(); + } + #[test] fn socket_path_target_uses_explicit_path() { let path = PathBuf::from("/tmp/herdr-test.sock"); diff --git a/src/api/event_hub.rs b/src/api/event_hub.rs index 42cf47a2..29843313 100644 --- a/src/api/event_hub.rs +++ b/src/api/event_hub.rs @@ -9,6 +9,12 @@ struct EventHubState { events: Vec<(u64, crate::api::schema::EventEnvelope)>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum EventHistoryError { + Lost, + Unavailable, +} + impl EventHub { const MAX_EVENTS: usize = 512; @@ -37,6 +43,29 @@ impl EventHub { .collect() } + pub(super) fn events_after_checked( + &self, + sequence: u64, + ) -> Result, EventHistoryError> { + let state = self + .inner + .lock() + .map_err(|_| EventHistoryError::Unavailable)?; + if state + .events + .first() + .is_some_and(|(first, _)| sequence < first.saturating_sub(1)) + { + return Err(EventHistoryError::Lost); + } + Ok(state + .events + .iter() + .filter(|(event_sequence, _)| *event_sequence > sequence) + .cloned() + .collect()) + } + pub fn current_sequence(&self) -> u64 { let Ok(state) = self.inner.lock() else { return 0; @@ -44,3 +73,55 @@ impl EventHub { state.next_sequence } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::schema::{EventData, EventEnvelope, EventKind}; + + fn event() -> EventEnvelope { + EventEnvelope { + event: EventKind::WorkspaceFocused, + data: EventData::WorkspaceFocused { + workspace_id: "workspace_1".into(), + }, + } + } + + #[test] + fn checked_history_distinguishes_retained_boundary_from_lost_events() { + let hub = EventHub::default(); + assert!(hub.events_after_checked(0).unwrap().is_empty()); + for _ in 0..EventHub::MAX_EVENTS { + hub.push(event()); + } + assert_eq!( + hub.events_after_checked(0).unwrap().len(), + EventHub::MAX_EVENTS + ); + hub.push(event()); + assert_eq!(hub.events_after_checked(0), Err(EventHistoryError::Lost)); + let retained = hub.events_after_checked(1).unwrap(); + assert_eq!(retained.len(), EventHub::MAX_EVENTS); + assert_eq!(retained.first().unwrap().0, 2); + assert_eq!(retained.last().unwrap().0, hub.current_sequence()); + assert!(hub + .events_after_checked(hub.current_sequence()) + .unwrap() + .is_empty()); + } + + #[test] + fn checked_history_reports_unavailable_instead_of_empty_after_poison() { + let hub = EventHub::default(); + assert!(std::panic::catch_unwind(|| { + let _guard = hub.inner.lock().unwrap(); + panic!("poison the test event history"); + }) + .is_err()); + assert_eq!( + hub.events_after_checked(0), + Err(EventHistoryError::Unavailable) + ); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index d28a82ae..86f90da8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -59,17 +59,11 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneFocusDirection(_) | Method::PaneResize(_) | Method::PaneScroll(_) + | Method::PaneClear(_) | Method::PaneEditScrollback(_) | Method::PaneFocus(_) | Method::PaneInputSet(_) | Method::PaneRename(_) - | Method::PaneGraphicsSet(_) - | Method::PaneGraphicsClear(_) - | Method::PaneGraphicsStream(_) - | Method::PaneGraphicsStreamSet(_) - | Method::PaneGraphicsStreamDirect(_) - | Method::PaneGraphicsStreamOpen(_) - | Method::PaneGraphicsStreamClose(_) | Method::PaneReportAgent(_) | Method::PaneReportAgentSession(_) | Method::PaneReportMetadata(_) @@ -90,7 +84,6 @@ pub struct ApiRequestMessage { pub request: Request, pub respond_to: std::sync::mpsc::Sender, pub response_write_complete: Option>, - pub stream_active: Option>, } pub type ApiRequestSender = mpsc::UnboundedSender; diff --git a/src/api/schema.rs b/src/api/schema.rs index b5c018cd..d91876e9 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -53,6 +53,8 @@ pub enum Method { ServerLiveHandoff(ServerLiveHandoffParams), #[serde(rename = "server.reload_config")] ServerReloadConfig(EmptyParams), + #[serde(rename = "server.ssh_agent.register")] + ServerSshAgentRegister(ServerSshAgentRegisterParams), #[serde(rename = "server.agent_manifests")] ServerAgentManifests(EmptyParams), #[serde(rename = "server.reload_agent_manifests")] @@ -181,6 +183,8 @@ pub enum Method { PaneResize(PaneResizeParams), #[serde(rename = "pane.scroll")] PaneScroll(PaneScrollParams), + #[serde(rename = "pane.clear")] + PaneClear(PaneTarget), #[serde(rename = "pane.edit_scrollback")] PaneEditScrollback(PaneTarget), #[serde(rename = "pane.selection.read")] @@ -213,27 +217,6 @@ pub enum Method { PaneSendInput(PaneSendInputParams), #[serde(rename = "pane.read")] PaneRead(PaneReadParams), - #[serde(rename = "pane.graphics.set")] - PaneGraphicsSet(PaneGraphicsSetParams), - #[serde(rename = "pane.graphics.clear")] - PaneGraphicsClear(PaneGraphicsClearParams), - #[serde(rename = "pane.graphics.info")] - PaneGraphicsInfo(PaneTarget), - #[serde(rename = "pane.graphics.stream")] - #[schemars(skip)] - PaneGraphicsStream(PaneGraphicsStreamParams), - #[serde(skip)] - #[schemars(skip)] - PaneGraphicsStreamSet(PaneGraphicsSetParams), - #[serde(skip)] - #[schemars(skip)] - PaneGraphicsStreamDirect(PaneGraphicsDirectParams), - #[serde(skip)] - #[schemars(skip)] - PaneGraphicsStreamOpen(PaneGraphicsStreamParams), - #[serde(skip)] - #[schemars(skip)] - PaneGraphicsStreamClose(PaneGraphicsStreamParams), #[serde(rename = "pane.report_agent")] PaneReportAgent(PaneReportAgentParams), #[serde(rename = "pane.report_agent_session")] diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index ee496d5d..48450c30 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -218,6 +218,9 @@ pub struct AgentInfo { pub interactive_ready: bool, #[serde(default)] pub state_change_seq: u64, + /// The current idle transition completed work, independently of who has viewed it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion_seq: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 6cbcaf43..9dbe0221 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -2,14 +2,6 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -pub(crate) const PANE_GRAPHICS_SET_MAX_BYTES: usize = 512 * 1024; -pub(crate) const PANE_GRAPHICS_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024; -pub(crate) const PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES: usize = 400 * 1024 * 1024; -pub(crate) const PANE_GRAPHICS_MAX_LAYERS_PER_PANE: usize = 16; -pub(crate) const PANE_GRAPHICS_MAX_LAYERS_TOTAL: usize = 64; -pub(crate) const PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL: usize = 64 * 1024 * 1024; -pub(crate) const PANE_GRAPHICS_PRIMARY_LAYER_ID: &str = "primary"; - use super::agents::AgentSessionInfo; use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection}; @@ -366,84 +358,6 @@ pub struct PaneReadParams { pub(crate) intent: super::common::ReadIntent, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum PaneGraphicsFormat { - Png, - Rgb, - Rgba, - Bgra, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct PaneGraphicsSetParams { - pub pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer_id: Option, - #[serde(default)] - pub z_index: i32, - #[serde(skip)] - #[schemars(skip)] - pub owner: String, - pub format: PaneGraphicsFormat, - pub image_width: u32, - pub image_height: u32, - #[serde(skip)] - pub data: Option>, - #[serde(default)] - pub data_base64: String, - #[serde(default)] - pub placement: PaneGraphicsPlacementParams, -} - -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default, -)] -pub struct PaneGraphicsPlacementParams { - #[serde(default)] - pub viewport_col: i32, - #[serde(default)] - pub viewport_row: i32, - #[serde(default)] - pub grid_cols: u32, - #[serde(default)] - pub grid_rows: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct PaneGraphicsClearParams { - pub pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer_id: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PaneGraphicsDirectParams { - pub pane_id: String, - pub layer_id: Option, - pub z_index: i32, - pub owner: String, - pub image_width: u32, - pub image_height: u32, - pub format: PaneGraphicsFormat, - pub path: String, - pub sequence: u64, - pub revision: u64, - pub placement: PaneGraphicsPlacementParams, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct PaneGraphicsStreamParams { - pub pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub layer_id: Option, - #[serde(default)] - pub z_index: i32, - #[serde(skip)] - #[schemars(skip)] - pub owner: String, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PaneReportAgentParams { pub pane_id: String, @@ -458,6 +372,10 @@ pub struct PaneReportAgentParams { pub agent_session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session_path: Option, + /// Command that resumes this agent's session after a Herdr restart. The + /// first element must be a plain command name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_argv: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -473,6 +391,10 @@ pub struct PaneReportAgentSessionParams { pub agent_session_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_start_source: Option, + /// Command that resumes this agent's session after a Herdr restart. The + /// first element must be a plain command name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_argv: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -535,6 +457,8 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub foreground_cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub restore_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option, diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 587e4f85..48a115d7 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -190,33 +190,6 @@ pub enum ResponseResult { #[serde(default, skip_serializing_if = "Option::is_none")] current_global: Option, }, - PaneGraphicsFrameAck { - sequence: u64, - revision: u64, - }, - PaneGraphicsInfo { - cell_width_px: u32, - cell_height_px: u32, - /// True only when this pane is on the currently rendered terminal surface. - pane_visible: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - file_frame_directory: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - file_frame_formats: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - file_frame_max_bytes: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - file_frame_direct_max_bytes: Option, - /// Accepts damage metadata while still consuming a complete canonical file. - #[serde(default)] - file_frame_damage: bool, - #[serde(default)] - max_layers_per_pane: usize, - #[serde(default)] - pixel_mouse: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - file_frame_transport: Option, - }, AgentExplain { explain: serde_json::Value, }, diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs index b274bc95..1adb62f5 100644 --- a/src/api/schema/server.rs +++ b/src/api/schema/server.rs @@ -13,6 +13,12 @@ pub struct ServerLiveHandoffParams { pub expected_version: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ServerSshAgentRegisterParams { + /// Absolute remote-host agent socket. Registration lasts until this API connection closes. + pub socket_path: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ServerCapabilities { pub live_handoff: bool, @@ -27,4 +33,7 @@ pub struct ServerCapabilities { /// Whether this server supports endpoint health probes. #[serde(default)] pub health_check: bool, + /// Supports connection-scoped `server.ssh_agent.register` on the local JSON API. + #[serde(default)] + pub ssh_agent_registration: bool, } diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 1ac10566..0d9a409d 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -726,6 +726,7 @@ fn success_response_round_trips() { endpoint_protocol_generation: Some(1), surface_interest: true, health_check: true, + ssh_agent_registration: false, }), superherdr_version: Some("0.1.2.1".into()), }, @@ -826,6 +827,7 @@ fn worktree_request_and_response_round_trip() { focused: true, cwd: Some("/worktrees/herdr/worktree-api".into()), foreground_cwd: None, + restore_error: None, label: None, agent: None, title: None, @@ -1254,6 +1256,7 @@ fn create_response_round_trips_with_root_pane() { focused: false, cwd: Some("/tmp/review".into()), foreground_cwd: None, + restore_error: None, label: None, agent: None, title: None, diff --git a/src/api/server.rs b/src/api/server.rs index 804a0881..d597659e 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -22,7 +22,8 @@ use crate::ipc::{ socket_file_identity, LocalStream, LocalStreamRead, SocketFileIdentity, }; -mod pane_graphics_stream; +#[cfg(test)] +mod subscription_socket_tests; const SOCKET_PERMISSION_MODE: u32 = 0o600; pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100); @@ -30,6 +31,7 @@ pub(super) const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); const MAX_INITIAL_REQUEST_BYTES: usize = 1024 * 1024; +const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_millis(50); pub struct ServerHandle { _thread: std::thread::JoinHandle<()>, @@ -71,13 +73,14 @@ fn default_capabilities() -> Option { endpoint_protocol_generation: Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), surface_interest: true, health_check: true, + ssh_agent_registration: false, }) } fn start_server_inner( api_tx: ApiRequestSender, event_hub: EventHub, - capabilities: Option, + mut capabilities: Option, server_stop: Option>, ) -> std::io::Result { let path = socket_path(); @@ -88,36 +91,62 @@ fn start_server_inner( let identity = socket_file_identity(&path)?; info!(path = %path.display(), "api server listening"); + #[cfg(unix)] + let ssh_agents = match crate::platform::ssh_agent::SshAgentRegistry::new( + crate::platform::ssh_agent::socket_path(), + std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from), + ) { + Ok(registry) => Some(registry), + Err(error) => { + warn!(%error, "SSH agent refresh unavailable; retaining inherited pane environment"); + None + } + }; + + if let Some(capabilities) = capabilities.as_mut() { + capabilities.ssh_agent_registration = { + #[cfg(unix)] + { + ssh_agents.is_some() + } + #[cfg(not(unix))] + { + false + } + }; + } + let running = Arc::new(AtomicBool::new(true)); let listener_running = Arc::clone(&running); let thread = std::thread::spawn(move || { - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let api_tx = api_tx.clone(); - let event_hub = event_hub.clone(); - let capabilities = capabilities.clone(); - let server_stop = server_stop.clone(); - let connection_running = Arc::clone(&listener_running); - std::thread::spawn(move || { - if let Err(err) = handle_connection_with_stop( - stream, - &api_tx, - &event_hub, - &connection_running, - capabilities, - server_stop.as_ref(), - ) { - warn!(err = %err, "api connection failed"); - } - }); - } - Err(err) => { - error!(err = %err, "api listener accept failed"); - break; - } - } - } + run_accept_loop( + listener.incoming(), + &listener_running, + ACCEPT_ERROR_BACKOFF, + |stream| { + let api_tx = api_tx.clone(); + let event_hub = event_hub.clone(); + let capabilities = capabilities.clone(); + let server_stop = server_stop.clone(); + let connection_running = Arc::clone(&listener_running); + #[cfg(unix)] + let ssh_agents = ssh_agents.clone(); + std::thread::spawn(move || { + if let Err(err) = handle_connection_with_stop( + stream, + &api_tx, + &event_hub, + &connection_running, + capabilities, + server_stop.as_ref(), + #[cfg(unix)] + ssh_agents.as_ref(), + ) { + warn!(err = %err, "api connection failed"); + } + }); + }, + ); debug!("api server thread exiting"); }); @@ -129,6 +158,108 @@ fn start_server_inner( }) } +fn run_accept_loop( + incoming: impl IntoIterator>, + running: &AtomicBool, + error_backoff: Duration, + mut handle: impl FnMut(S), +) { + let mut consecutive_errors = 0_u64; + for stream in incoming { + match stream { + Ok(stream) => { + if consecutive_errors > 0 { + info!(consecutive_errors, "api listener accept recovered"); + consecutive_errors = 0; + } + handle(stream); + } + Err(err) => { + if !running.load(Ordering::Relaxed) { + break; + } + // Accept errors such as ECONNABORTED or EMFILE are transient; + // exiting would leave the socket file with no listener. + if consecutive_errors == 0 { + error!(err = %err, "api listener accept failed; retrying"); + } + consecutive_errors = consecutive_errors.saturating_add(1); + std::thread::sleep(error_backoff); + if !running.load(Ordering::Relaxed) { + break; + } + } + } + } +} + +#[cfg(test)] +mod accept_loop_tests { + use super::*; + + fn accept_error() -> io::Result { + Err(io::Error::other("transient accept failure")) + } + + #[test] + fn keeps_serving_after_repeated_errors() { + let running = AtomicBool::new(true); + let mut handled = Vec::new(); + + run_accept_loop( + [Ok(1), accept_error(), accept_error(), Ok(2)], + &running, + Duration::ZERO, + |stream| handled.push(stream), + ); + + assert_eq!(handled, vec![1, 2]); + } + + #[test] + fn exits_when_shutdown_happens_while_errors_continue() { + let running = AtomicBool::new(true); + let mut attempts = 0; + let incoming = std::iter::from_fn(|| { + attempts += 1; + if attempts == 3 { + running.store(false, Ordering::Relaxed); + } + Some(accept_error()) + }); + + run_accept_loop(incoming, &running, Duration::ZERO, |_| { + panic!("no connection should be handled") + }); + + assert_eq!(attempts, 3); + } +} + +fn retired_pane_graphics_method_error(line: &str, id: &str) -> Option { + #[derive(serde::Deserialize)] + struct RequestMethod { + method: String, + } + + let envelope = serde_json::from_str::(line).ok()?; + let method = envelope.method.as_str(); + if !matches!( + method, + "pane.graphics.info" | "pane.graphics.set" | "pane.graphics.clear" | "pane.graphics.stream" + ) { + return None; + } + + Some(ErrorResponse { + id: id.into(), + error: ErrorBody { + code: "unknown_method".into(), + message: format!("unknown method: {method}"), + }, + }) +} + fn prepare_socket_path(path: &Path) -> std::io::Result<()> { crate::ipc::prepare_socket_path(path, |path| { format!( @@ -150,7 +281,16 @@ fn handle_connection( running: &Arc, capabilities: Option, ) -> std::io::Result<()> { - handle_connection_with_stop(stream, api_tx, event_hub, running, capabilities, None) + handle_connection_with_stop( + stream, + api_tx, + event_hub, + running, + capabilities, + None, + #[cfg(unix)] + None, + ) } fn handle_connection_with_stop( @@ -160,6 +300,7 @@ fn handle_connection_with_stop( running: &Arc, capabilities: Option, server_stop: Option<&Arc>, + #[cfg(unix)] ssh_agents: Option<&crate::platform::ssh_agent::SshAgentRegistry>, ) -> std::io::Result<()> { if let Err(err) = stream.set_send_timeout(Some(STREAM_WRITE_TIMEOUT)) { debug!(err = %err, "api connection write timeout unavailable"); @@ -177,16 +318,28 @@ fn handle_connection_with_stop( let request = match serde_json::from_str::(line) { Ok(request) => request, Err(request_error) => { - write_json_line_allow_disconnect( - &mut stream, - &ErrorResponse { - id: String::new(), + // Recover correlation without relaxing typed request validation or accepting + // ambiguous duplicate IDs. Invalid JSON and non-string IDs stay uncorrelated. + #[derive(serde::Deserialize)] + struct RequestId { + id: String, + } + let id = if line.starts_with('{') { + serde_json::from_str::(line) + .map(|request| request.id) + .unwrap_or_default() + } else { + String::new() + }; + let response = + retired_pane_graphics_method_error(line, &id).unwrap_or_else(|| ErrorResponse { + id, error: ErrorBody { code: "invalid_request".into(), message: format!("invalid request: {request_error}"), }, - }, - )?; + }); + write_json_line_allow_disconnect(&mut stream, &response)?; return Ok(()); } }; @@ -197,21 +350,48 @@ fn handle_connection_with_stop( crate::logging::api_request_started(&request_id, method, changes_ui); match request.method { - Method::PaneGraphicsStream(params) => { - let result = - pane_graphics_stream::serve(stream, request_id.clone(), params, api_tx, running); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - "stream_closed", - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) + #[cfg(unix)] + Method::ServerSshAgentRegister(params) => { + let lease = ssh_agents + .ok_or_else(|| io::Error::other("SSH agent registration is unavailable")) + .and_then(|registry| registry.register(PathBuf::from(params.socket_path))); + let lease = match lease { + Ok(lease) => lease, + Err(error) => { + return write_text_line_allow_disconnect( + &mut stream, + &error_response_json( + request_id, + if error.kind() == io::ErrorKind::InvalidInput { + "invalid_ssh_agent" + } else { + "ssh_agent_unavailable" + }, + error.to_string(), + ), + ) + } + }; + write_json_line( + &mut stream, + &SuccessResponse { + id: request_id, + result: ResponseResult::Ok {}, + }, + )?; + set_local_stream_polling(&mut stream, true)?; + let mut byte = [0]; + while running.load(Ordering::Relaxed) { + match poll_local_stream_read(&mut stream, &mut byte)? { + LocalStreamRead::Pending => { + // SSH can unlink an inherited socket after its bridge's lease closes. + lease.refresh()?; + std::thread::sleep(CONNECTION_POLL_INTERVAL); + } + _ => break, } } - result + Ok(()) } Method::EventsSubscribe(params) => { let result = stream_subscriptions( @@ -380,7 +560,7 @@ fn handle_request( ); } - dispatch_to_app(request, api_tx, None, response_write_complete, None, None) + dispatch_to_app(request, api_tx, None, response_write_complete, None) } pub(crate) fn api_method_name(method: &Method) -> &'static str { @@ -389,6 +569,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::ServerStop(_) => "server.stop", Method::ServerLiveHandoff(_) => "server.live_handoff", Method::ServerReloadConfig(_) => "server.reload_config", + Method::ServerSshAgentRegister(_) => "server.ssh_agent.register", Method::ServerAgentManifests(_) => "server.agent_manifests", Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests", Method::NotificationShow(_) => "notification.show", @@ -453,6 +634,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::PaneFocusDirection(_) => "pane.focus_direction", Method::PaneResize(_) => "pane.resize", Method::PaneScroll(_) => "pane.scroll", + Method::PaneClear(_) => "pane.clear", Method::PaneEditScrollback(_) => "pane.edit_scrollback", Method::PaneSelectionRead(_) => "pane.selection.read", Method::PaneCopyMotion(_) => "pane.copy_motion", @@ -469,14 +651,6 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::PaneSendKeys(_) => "pane.send_keys", Method::PaneSendInput(_) => "pane.send_input", Method::PaneRead(_) => "pane.read", - Method::PaneGraphicsSet(_) => "pane.graphics.set", - Method::PaneGraphicsClear(_) => "pane.graphics.clear", - Method::PaneGraphicsInfo(_) => "pane.graphics.info", - Method::PaneGraphicsStream(_) => "pane.graphics.stream", - Method::PaneGraphicsStreamSet(_) => "pane.graphics.stream.set", - Method::PaneGraphicsStreamDirect(_) => "pane.graphics.stream.direct", - Method::PaneGraphicsStreamOpen(_) => "pane.graphics.stream.open", - Method::PaneGraphicsStreamClose(_) => "pane.graphics.stream.close", Method::PaneReportAgent(_) => "pane.report_agent", Method::PaneReportAgentSession(_) => "pane.report_agent_session", Method::PaneReportMetadata(_) => "pane.report_metadata", @@ -727,7 +901,8 @@ fn stream_subscriptions( event_start_sequence, ) { Ok(active) => active, - Err(response) => { + Err(mut response) => { + response.id = request_id; if let Err(err) = write_json_line(&mut stream, &response) { if is_connection_closed_error(&err) { return Ok(()); @@ -743,7 +918,7 @@ fn stream_subscriptions( if let Err(err) = write_json_line( &mut stream, &SuccessResponse { - id: request_id, + id: request_id.clone(), result: ResponseResult::SubscriptionStarted {}, }, ) { @@ -759,7 +934,23 @@ fn stream_subscriptions( } for subscription in &mut subscriptions { - if let Some(event) = subscription.poll(api_tx, event_hub) { + let events = match subscription.poll_batch(api_tx, event_hub) { + Ok(events) => events, + Err(error) => { + write_json_line_allow_disconnect( + &mut stream, + &ErrorResponse { + id: request_id, + error, + }, + )?; + return Ok(()); + } + }; + for event in events { + if should_stop_connection(&mut stream, running)? { + return Ok(()); + } if let Err(err) = write_json_line(&mut stream, &event) { if is_connection_closed_error(&err) { return Ok(()); @@ -819,7 +1010,7 @@ pub(super) fn dispatch_to_app_with_timeout( api_tx: &ApiRequestSender, timeout: Option, ) -> String { - dispatch_to_app(request, api_tx, timeout, None, None, None) + dispatch_to_app(request, api_tx, timeout, None, None) } pub(super) fn dispatch_to_app_with_caller_timeout( @@ -832,55 +1023,24 @@ pub(super) fn dispatch_to_app_with_caller_timeout( api_tx, timeout, None, - None, Some(("timeout", "timed out waiting for agent status")), ) } -pub(super) fn dispatch_stream_open( - request: Request, - api_tx: &ApiRequestSender, - timeout: Duration, - active: Arc, -) -> String { - dispatch_to_app(request, api_tx, Some(timeout), None, Some(active), None) -} - -pub(super) fn dispatch_stream_frame( - request: Request, - api_tx: &ApiRequestSender, - active: Arc, -) -> String { - dispatch_to_app( - request, - api_tx, - Some(crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT), - None, - Some(active), - None, - ) -} - fn dispatch_to_app( request: Request, api_tx: &ApiRequestSender, timeout: Option, response_write_complete: Option>, - stream_active: Option>, timeout_response: Option<(&str, &str)>, ) -> String { let request_id = request.id.clone(); - let request_active = stream_active.clone(); let (respond_to, response_rx) = std::sync::mpsc::channel(); if let Err(err) = api_tx.send(ApiRequestMessage { request, respond_to, response_write_complete, - stream_active, }) { - if let Some(active) = request_active { - active.store(false, Ordering::Release); - } return error_response_json( request_id, "server_unavailable", @@ -910,9 +1070,6 @@ fn dispatch_to_app( match response { Ok(response) => response, Err(err) => { - if let Some(active) = request_active { - active.store(false, Ordering::Release); - } if err.kind() == std::io::ErrorKind::TimedOut { if let Some((code, message)) = timeout_response { return error_response_json(request_id, code, message.into()); @@ -966,7 +1123,7 @@ mod tests { use super::*; use interprocess::local_socket::traits::Listener as _; use std::collections::HashMap; - use std::io::{BufRead, BufReader}; + use std::io::{BufRead, BufReader, Read}; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; use std::sync::{Mutex, OnceLock}; @@ -1000,6 +1157,53 @@ mod tests { (client, server, path) } + #[test] + fn ssh_agent_registration_lasts_only_for_the_api_connection() { + let directory = unique_test_path("agent-lease"); + fs::create_dir(&directory).unwrap(); + let agent = directory.join("upstream"); + let _agent = UnixListener::bind(&agent).unwrap(); + let stable = directory.join("stable"); + let registry = + crate::platform::ssh_agent::SshAgentRegistry::new(stable.clone(), None).unwrap(); + let (mut client, server, api_path) = local_stream_pair("agent-api"); + let (tx, _rx) = mpsc::unbounded_channel(); + let worker_registry = registry.clone(); + let worker = std::thread::spawn(move || { + handle_connection_with_stop( + server, + &tx, + &EventHub::default(), + &Arc::new(AtomicBool::new(true)), + None, + None, + Some(&worker_registry), + ) + .unwrap(); + }); + write_json_line( + &mut client, + &Request { + id: "agent-lease".into(), + method: Method::ServerSshAgentRegister( + crate::api::schema::ServerSshAgentRegisterParams { + socket_path: agent.to_string_lossy().into_owned(), + }, + ), + }, + ) + .unwrap(); + let response: SuccessResponse = serde_json::from_str(&read_line(&mut client)).unwrap(); + assert!(matches!(response.result, ResponseResult::Ok {})); + assert_eq!(fs::read_link(&stable).unwrap(), agent); + drop(client); + worker.join().unwrap(); + assert!(!stable.exists()); + drop(registry); + fs::remove_file(api_path).unwrap(); + fs::remove_dir_all(directory).unwrap(); + } + fn pane_info( pane_id: &str, agent_status: crate::api::schema::AgentStatus, @@ -1012,6 +1216,7 @@ mod tests { focused: true, cwd: None, foreground_cwd: None, + restore_error: None, label: None, agent: Some("pi".into()), title: None, @@ -1142,6 +1347,93 @@ mod tests { assert_eq!(api_response_outcome(generic_error), "error"); } + #[test] + fn removed_pane_graphics_methods_return_unknown_method_without_stream_upgrade() { + for method in [ + "pane.graphics.info", + "pane.graphics.set", + "pane.graphics.clear", + "pane.graphics.stream", + ] { + let (mut client, server, _path) = local_stream_pair("removed-pane-graphics"); + let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); + writeln!( + client, + "{{\"id\":\"removed\",\"method\":\"{method}\",\"params\":{{}}}}" + ) + .unwrap(); + client.flush().unwrap(); + + handle_connection( + server, + &api_tx, + &EventHub::default(), + &Arc::new(AtomicBool::new(true)), + None, + ) + .unwrap(); + + let response = read_line(&mut client); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["id"], "removed", "{method}"); + assert_eq!(response["error"]["code"], "unknown_method", "{method}"); + assert!(response["error"]["message"] + .as_str() + .is_some_and(|message| message.contains(method))); + assert!(response.get("result").is_none(), "{method}"); + assert!(api_rx.try_recv().is_err(), "{method} reached the app"); + } + } + + #[test] + fn unrelated_unknown_method_retains_standard_invalid_request_response() { + let (mut client, server, _path) = local_stream_pair("unknown-api-request"); + let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); + client + .write_all(b"{\"id\":\"unknown\",\"method\":\"nope\",\"params\":{}}\n") + .unwrap(); + client.flush().unwrap(); + + handle_connection( + server, + &api_tx, + &EventHub::default(), + &Arc::new(AtomicBool::new(true)), + None, + ) + .unwrap(); + + let response = read_line(&mut client); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["id"], "unknown"); + assert_eq!(response["error"]["code"], "invalid_request"); + assert!(api_rx.try_recv().is_err()); + } + + #[test] + fn ordinary_api_request_still_uses_normal_connection_path() { + let (mut client, server, _path) = local_stream_pair("ordinary-api-request"); + let (api_tx, _api_rx) = mpsc::unbounded_channel::(); + client + .write_all(b"{\"id\":\"ordinary\",\"method\":\"ping\",\"params\":{}}\n") + .unwrap(); + client.flush().unwrap(); + + handle_connection( + server, + &api_tx, + &EventHub::default(), + &Arc::new(AtomicBool::new(true)), + None, + ) + .unwrap(); + + let response = read_line(&mut client); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["id"], "ordinary"); + assert_eq!(response["result"]["type"], "pong"); + } + #[test] fn ping_request_returns_pong() { let (tx, _rx) = mpsc::unbounded_channel(); @@ -1159,6 +1451,7 @@ mod tests { ), surface_interest: true, health_check: true, + ssh_agent_registration: false, }), None, None, @@ -1446,6 +1739,95 @@ mod tests { responder.join().unwrap(); } + #[test] + fn invalid_requests_preserve_only_unambiguous_string_ids() { + let cases = [ + ( + r#"{"id":"mine","method":"pane.report_agent","params":{"pane_id":"w1:p1","status":"working","source":"x"}}"#, + "mine", + ), + (r#"{"id":"escaped\"id","method":"unknown"}"#, "escaped\"id"), + (r#"{"method":"unknown","params":{"id":"nested"}}"#, ""), + (r#"{"id":123,"method":"unknown"}"#, ""), + ( + r#"{"id":"first","id":"second","method":"ping","params":{}}"#, + "", + ), + (r#"{"id":"truncated","method":"ping""#, ""), + (r#"["not-an-object"]"#, ""), + ]; + for (request, expected_id) in cases { + let (api_tx, mut api_rx) = mpsc::unbounded_channel(); + let (mut client, server, path) = local_stream_pair("invalid-request-id"); + writeln!(client, "{request}").unwrap(); + let running = Arc::new(AtomicBool::new(true)); + handle_connection(server, &api_tx, &EventHub::default(), &running, None).unwrap(); + + let mut response = String::new(); + BufReader::new(client) + .read_to_string(&mut response) + .unwrap(); + let response: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(response.id, expected_id, "{request}"); + assert_eq!(response.error.code, "invalid_request"); + assert!(response.error.message.starts_with("invalid request: ")); + assert!( + api_rx.try_recv().is_err(), + "invalid requests must not dispatch" + ); + fs::remove_file(path).unwrap(); + } + } + + #[test] + fn subscription_setup_errors_preserve_request_id_and_reject_entire_stream() { + let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); + let event_hub = EventHub::default(); + let responder_event_hub = event_hub.clone(); + let responder = std::thread::spawn(move || { + let msg = api_rx.blocking_recv().unwrap(); + let Method::PaneGet(params) = msg.request.method else { + panic!("unexpected request: {:?}", msg.request.method); + }; + assert_eq!(params.pane_id, "w999:p9"); + responder_event_hub.push(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneClosed, + data: crate::api::schema::EventData::PaneClosed { + pane_id: "w999:p9".into(), + workspace_id: "w999".into(), + }, + }); + msg.respond_to + .send(error_response_json( + msg.request.id, + "pane_not_found", + "pane w999:p9 not found".into(), + )) + .unwrap(); + assert!( + api_rx.blocking_recv().is_none(), + "rejection must not start polling" + ); + }); + let (mut client, server, path) = local_stream_pair("subscription-error-id"); + let request = r#"{"id":"panefold:events","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"},{"type":"pane.closed"},{"type":"pane.agent_status_changed","pane_id":"w999:p9"}]}}"#; + writeln!(client, "{request}").unwrap(); + let running = Arc::new(AtomicBool::new(true)); + handle_connection(server, &api_tx, &event_hub, &running, None).unwrap(); + drop(api_tx); + responder.join().unwrap(); + + let mut response = String::new(); + BufReader::new(client) + .read_to_string(&mut response) + .unwrap(); + let response: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(response.id, "panefold:events"); + assert_eq!(response.error.code, "pane_not_found"); + assert_eq!(response.error.message, "pane w999:p9 not found"); + fs::remove_file(path).unwrap(); + } + #[test] fn subscriptions_stop_when_client_disconnects() { let (api_tx, _api_rx) = mpsc::unbounded_channel::(); @@ -1470,6 +1852,7 @@ mod tests { let ack = read_line(&mut client); let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); assert_eq!(ack["result"]["type"], "subscription_started"); + assert_eq!(ack["id"], "sub_1"); drop(client); @@ -1510,39 +1893,3 @@ mod tests { server_thread.join().unwrap(); } } - -#[cfg(test)] -mod pane_graphics_request_tests { - use super::*; - use base64::Engine as _; - - #[test] - fn maximum_public_graphics_request_fits_initial_json_line() { - let request = Request { - id: "graphics-max".into(), - method: Method::PaneGraphicsSet(crate::api::schema::PaneGraphicsSetParams { - pane_id: "pane_1".into(), - layer_id: None, - z_index: 0, - owner: String::new(), - format: crate::api::schema::PaneGraphicsFormat::Png, - image_width: 1, - image_height: 1, - data_base64: base64::engine::general_purpose::STANDARD - .encode(vec![1_u8; crate::api::schema::PANE_GRAPHICS_SET_MAX_BYTES]), - data: None, - placement: crate::api::schema::PaneGraphicsPlacementParams::default(), - }), - }; - let encoded = serde_json::to_vec(&request).unwrap(); - - assert!(encoded.len() < MAX_INITIAL_REQUEST_BYTES); - } - - #[test] - fn duplicate_method_cannot_be_reinterpreted_as_graphics_stream() { - let encoded = r#"{"id":"duplicate","method":"ping","method":"pane.graphics.stream","params":{"pane_id":"pane_1"}}"#; - - assert!(serde_json::from_str::(encoded).is_err()); - } -} diff --git a/src/api/server/pane_graphics_stream.rs b/src/api/server/pane_graphics_stream.rs deleted file mode 100644 index 55816b1d..00000000 --- a/src/api/server/pane_graphics_stream.rs +++ /dev/null @@ -1,1114 +0,0 @@ -use std::io::{self, Read}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use interprocess::local_socket::traits::Stream as _; - -use crate::api::schema::{ - ErrorBody, ErrorResponse, Method, PaneGraphicsSetParams, PaneGraphicsStreamParams, Request, - ResponseResult, SuccessResponse, -}; -use crate::api::ApiRequestSender; -use crate::ipc::{is_connection_closed_error, LocalStream}; - -use super::{ - api_response_outcome, dispatch_stream_frame, dispatch_stream_open, - dispatch_to_app_with_timeout, write_json_line, write_json_line_allow_disconnect, - write_text_line_allow_disconnect, APP_RESPONSE_TIMEOUT, CONNECTION_POLL_INTERVAL, -}; - -const MAX_STREAM_FRAME_HEADER_BYTES: usize = 64 * 1024; -const STREAM_FRAME_BODY_CHUNK_BYTES: usize = 64 * 1024; -const STREAM_FRAME_HEADER_IDLE_TIMEOUT: Duration = Duration::from_secs(5); -const STREAM_FRAME_HEADER_TIMEOUT: Duration = Duration::from_secs(30); -const STREAM_FRAME_BODY_IDLE_TIMEOUT: Duration = Duration::from_secs(5); -const STREAM_FRAME_BODY_TIMEOUT: Duration = Duration::from_secs(30); -const STREAM_FALLBACK_POLL_INTERVAL: Duration = Duration::from_millis(1); -const STREAM_FALLBACK_FAST_POLLS: u8 = 32; -static NEXT_PANE_GRAPHICS_STREAM_OWNER: AtomicU64 = AtomicU64::new(1); - -#[derive(serde::Deserialize)] -struct FrameFile { - path: String, -} - -#[derive(serde::Deserialize)] -struct FrameHeader { - format: crate::api::schema::PaneGraphicsFormat, - image_width: u32, - image_height: u32, - #[serde(default)] - data_length: Option, - #[serde(default)] - file: Option, - #[serde(default)] - sequence: u64, - #[serde(default)] - revision: u64, - #[serde(default)] - placement: crate::api::schema::PaneGraphicsPlacementParams, -} - -#[derive(Clone, Copy)] -struct ReadTimeouts { - header_idle: Duration, - header_total: Duration, - body_idle: Duration, - body_total: Duration, -} - -const READ_TIMEOUTS: ReadTimeouts = ReadTimeouts { - header_idle: STREAM_FRAME_HEADER_IDLE_TIMEOUT, - header_total: STREAM_FRAME_HEADER_TIMEOUT, - body_idle: STREAM_FRAME_BODY_IDLE_TIMEOUT, - body_total: STREAM_FRAME_BODY_TIMEOUT, -}; - -pub(super) fn serve( - stream: LocalStream, - request_id: String, - params: PaneGraphicsStreamParams, - api_tx: &ApiRequestSender, - running: &Arc, -) -> std::io::Result<()> { - serve_with_open_timeout( - stream, - request_id, - params, - api_tx, - running, - APP_RESPONSE_TIMEOUT, - ) -} - -fn serve_with_open_timeout( - stream: LocalStream, - request_id: String, - params: PaneGraphicsStreamParams, - api_tx: &ApiRequestSender, - running: &Arc, - open_timeout: Duration, -) -> std::io::Result<()> { - serve_with_timeouts( - stream, - request_id, - params, - api_tx, - running, - open_timeout, - READ_TIMEOUTS, - ) -} - -fn serve_with_timeouts( - mut stream: LocalStream, - request_id: String, - mut params: PaneGraphicsStreamParams, - api_tx: &ApiRequestSender, - running: &Arc, - open_timeout: Duration, - read_timeouts: ReadTimeouts, -) -> std::io::Result<()> { - let pane_id = params.pane_id.clone(); - let layer_id = params.layer_id.clone(); - let z_index = params.z_index; - let owner = next_owner(); - params.owner = owner.clone(); - let stream_active = Arc::new(AtomicBool::new(true)); - let open_response = dispatch_stream_open( - Request { - id: request_id.clone(), - method: Method::PaneGraphicsStreamOpen(params), - }, - api_tx, - open_timeout, - Arc::clone(&stream_active), - ); - if api_response_outcome(&open_response) != "ok" { - stream_active.store(false, Ordering::Release); - let write_result = write_text_line_allow_disconnect(&mut stream, &open_response); - clear_layer(&pane_id, layer_id.as_deref(), z_index, &owner, api_tx); - write_result?; - return Ok(()); - } - - if let Err(err) = write_json_line( - &mut stream, - &SuccessResponse { - id: request_id.clone(), - result: ResponseResult::Ok {}, - }, - ) { - stream_active.store(false, Ordering::Release); - clear_layer(&pane_id, layer_id.as_deref(), z_index, &owner, api_tx); - if is_connection_closed_error(&err) { - return Ok(()); - } - return Err(err); - } - - let result = serve_frames( - &mut stream, - &request_id, - &owner, - &pane_id, - layer_id.as_deref(), - z_index, - api_tx, - running, - &stream_active, - read_timeouts, - ); - stream_active.store(false, Ordering::Release); - clear_layer(&pane_id, layer_id.as_deref(), z_index, &owner, api_tx); - result -} - -fn serve_frames( - stream: &mut LocalStream, - request_id: &str, - owner: &str, - pane_id: &str, - layer_id: Option<&str>, - z_index: i32, - api_tx: &ApiRequestSender, - running: &Arc, - stream_active: &Arc, - timeouts: ReadTimeouts, -) -> std::io::Result<()> { - let mut frame_seq = 0_u64; - while stream_is_running(running, stream_active) { - let Some(header_line) = read_line( - stream, - running, - stream_active, - MAX_STREAM_FRAME_HEADER_BYTES, - timeouts.header_idle, - timeouts.header_total, - )? - else { - return Ok(()); - }; - let header_line = header_line.trim(); - if header_line.is_empty() { - continue; - } - let header = match serde_json::from_str::(header_line) { - Ok(header) => header, - Err(err) => { - write_json_line_allow_disconnect( - stream, - &ErrorResponse { - id: request_id.to_string(), - error: ErrorBody { - code: "invalid_frame".into(), - message: format!("invalid frame header: {err}"), - }, - }, - )?; - return Ok(()); - } - }; - if let Some(file) = header.file { - if !matches!( - header.format, - crate::api::schema::PaneGraphicsFormat::Rgba - | crate::api::schema::PaneGraphicsFormat::Bgra - ) { - write_json_line_allow_disconnect( - stream, - &ErrorResponse { - id: request_id.to_string(), - error: ErrorBody { - code: "invalid_frame".into(), - message: "file frames require rgba or bgra".into(), - }, - }, - )?; - return Ok(()); - } - let response = dispatch_stream_frame( - Request { - id: format!("{request_id}:file:{}", header.sequence), - method: Method::PaneGraphicsStreamDirect( - crate::api::schema::PaneGraphicsDirectParams { - pane_id: pane_id.to_owned(), - layer_id: layer_id.map(str::to_owned), - z_index, - owner: owner.to_owned(), - image_width: header.image_width, - image_height: header.image_height, - format: header.format, - path: file.path, - sequence: header.sequence, - revision: header.revision, - placement: header.placement, - }, - ), - }, - api_tx, - Arc::clone(stream_active), - ); - write_text_line_allow_disconnect(stream, &response)?; - if api_response_outcome(&response) != "ok" { - return Ok(()); - } - continue; - } - - let Some(data_length) = header.data_length else { - write_json_line_allow_disconnect( - stream, - &ErrorResponse { - id: request_id.to_string(), - error: ErrorBody { - code: "invalid_frame".into(), - message: "frame requires data_length or file".into(), - }, - }, - )?; - return Ok(()); - }; - if data_length == 0 { - write_json_line_allow_disconnect( - stream, - &ErrorResponse { - id: request_id.to_string(), - error: ErrorBody { - code: "invalid_frame".into(), - message: "frame data_length must be greater than zero".into(), - }, - }, - )?; - return Ok(()); - } - if data_length > crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES { - write_json_line_allow_disconnect( - stream, - &ErrorResponse { - id: request_id.to_string(), - error: ErrorBody { - code: "image_too_large".into(), - message: "frame data is too large".into(), - }, - }, - )?; - return Ok(()); - } - - let Some(data) = read_exact( - stream, - data_length, - running, - stream_active, - timeouts.body_idle, - timeouts.body_total, - )? - else { - return Ok(()); - }; - - frame_seq = frame_seq.saturating_add(1); - let frame_id = format!("{request_id}:frame:{frame_seq}"); - let response = dispatch_to_app_with_timeout( - Request { - id: frame_id, - method: Method::PaneGraphicsStreamSet(PaneGraphicsSetParams { - pane_id: pane_id.to_string(), - layer_id: layer_id.map(str::to_owned), - z_index, - owner: owner.to_string(), - format: header.format, - image_width: header.image_width, - image_height: header.image_height, - data: Some(data), - data_base64: String::new(), - placement: header.placement, - }), - }, - api_tx, - Some(APP_RESPONSE_TIMEOUT), - ); - if api_response_outcome(&response) != "ok" { - write_text_line_allow_disconnect(stream, &response)?; - return Ok(()); - } - } - - Ok(()) -} - -fn stream_is_running(running: &AtomicBool, stream_active: &AtomicBool) -> bool { - running.load(Ordering::Relaxed) && stream_active.load(Ordering::Acquire) -} - -fn next_owner() -> String { - let id = NEXT_PANE_GRAPHICS_STREAM_OWNER.fetch_add(1, Ordering::Relaxed); - format!("pane.graphics.stream:{}:{id}", std::process::id()) -} - -fn clear_layer( - pane_id: &str, - layer_id: Option<&str>, - z_index: i32, - owner: &str, - api_tx: &ApiRequestSender, -) { - let _response = dispatch_to_app_with_timeout( - Request { - id: format!("pane.graphics.stream.clear:{pane_id}"), - method: Method::PaneGraphicsStreamClose(PaneGraphicsStreamParams { - pane_id: pane_id.to_string(), - layer_id: layer_id.map(str::to_owned), - z_index, - owner: owner.to_string(), - }), - }, - api_tx, - Some(APP_RESPONSE_TIMEOUT), - ); -} - -fn read_line( - stream: &mut LocalStream, - running: &Arc, - stream_active: &Arc, - max_bytes: usize, - idle_timeout: Duration, - total_timeout: Duration, -) -> std::io::Result> { - with_timed_reads(stream, |stream, mut wait| { - let mut bytes = Vec::new(); - let mut byte = [0_u8; 1]; - let mut total_deadline = None; - let mut idle_deadline = None; - - loop { - if !stream_is_running(running, stream_active) { - return Ok(None); - } - ensure_before_deadlines( - idle_deadline, - total_deadline, - "timed out reading stream frame header", - )?; - match wait.read(stream, &mut byte) { - Ok(0) => return Ok(None), - Ok(_) => { - wait.on_progress(); - let now = Instant::now(); - let total_deadline_at = - *total_deadline.get_or_insert_with(|| now + total_timeout); - idle_deadline = Some(now + idle_timeout); - if now >= total_deadline_at { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "timed out reading stream frame header", - )); - } - bytes.push(byte[0]); - if byte[0] == b'\n' { - return String::from_utf8(bytes) - .map(Some) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); - } - if bytes.len() > max_bytes { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "stream frame header is too large", - )); - } - } - Err(err) if read_should_retry(&err) => { - wait.after_retry(idle_deadline, total_deadline); - } - Err(err) if is_connection_closed_error(&err) => return Ok(None), - Err(err) => return Err(err), - } - } - }) -} - -fn read_exact( - stream: &mut LocalStream, - len: usize, - running: &Arc, - stream_active: &Arc, - idle_timeout: Duration, - total_timeout: Duration, -) -> std::io::Result>> { - with_timed_reads(stream, |stream, mut wait| { - let mut data = Vec::new(); - let mut chunk = vec![0_u8; STREAM_FRAME_BODY_CHUNK_BYTES.min(len)]; - let total_deadline = Instant::now() + total_timeout; - let mut idle_deadline = Instant::now() + idle_timeout; - - while data.len() < len { - if !stream_is_running(running, stream_active) { - return Ok(None); - } - ensure_before_deadlines( - Some(idle_deadline), - Some(total_deadline), - "timed out reading stream frame body", - )?; - let remaining = len - data.len(); - let read_len = remaining.min(chunk.len()); - match wait.read(stream, &mut chunk[..read_len]) { - Ok(0) if data.is_empty() => return Ok(None), - Ok(0) => { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "stream ended mid-frame", - )); - } - Ok(n) => { - wait.on_progress(); - let now = Instant::now(); - if now >= total_deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "timed out reading stream frame body", - )); - } - data.extend_from_slice(&chunk[..n]); - idle_deadline = now + idle_timeout; - } - Err(err) if read_should_retry(&err) => { - wait.after_retry(Some(idle_deadline), Some(total_deadline)); - } - Err(err) if is_connection_closed_error(&err) && data.is_empty() => return Ok(None), - Err(err) => return Err(err), - } - } - - Ok(Some(data)) - }) -} - -#[derive(Clone, Copy)] -enum ReadWait { - SocketTimeout, - Poll(PollBackoff), -} - -impl ReadWait { - fn read(&self, stream: &mut LocalStream, buffer: &mut [u8]) -> io::Result { - if matches!(self, Self::SocketTimeout) { - return stream.read(buffer); - } - match crate::ipc::poll_local_stream_read_count(stream, buffer)? { - crate::ipc::LocalStreamReadCount::Data(count) => Ok(count), - crate::ipc::LocalStreamReadCount::Pending => Err(io::ErrorKind::WouldBlock.into()), - crate::ipc::LocalStreamReadCount::Closed => Ok(0), - } - } - - fn after_retry(&mut self, idle_deadline: Option, total_deadline: Option) { - if let Self::Poll(backoff) = self { - sleep_until_poll(idle_deadline, total_deadline, backoff.interval); - backoff.advance(); - } - } - - fn on_progress(&mut self) { - if let Self::Poll(backoff) = self { - backoff.reset(); - } - } -} - -#[derive(Clone, Copy)] -struct PollBackoff { - interval: Duration, - fast_polls_remaining: u8, -} - -impl PollBackoff { - fn new() -> Self { - Self { - interval: STREAM_FALLBACK_POLL_INTERVAL, - fast_polls_remaining: STREAM_FALLBACK_FAST_POLLS, - } - } - - fn advance(&mut self) { - if self.fast_polls_remaining > 0 { - self.fast_polls_remaining -= 1; - return; - } - self.interval = (self.interval * 2).min(CONNECTION_POLL_INTERVAL); - } - - fn reset(&mut self) { - *self = Self::new(); - } -} - -fn with_timed_reads( - stream: &mut LocalStream, - read: impl FnOnce(&mut LocalStream, ReadWait) -> std::io::Result>, -) -> std::io::Result> { - match stream.set_recv_timeout(Some(CONNECTION_POLL_INTERVAL)) { - Ok(()) => { - let result = read(stream, ReadWait::SocketTimeout); - finish_timed_read(result, || stream.set_recv_timeout(None)) - } - Err(err) if err.kind() == io::ErrorKind::Unsupported => { - crate::ipc::set_local_stream_polling(stream, true)?; - let result = read(stream, ReadWait::Poll(PollBackoff::new())); - finish_timed_read(result, || { - crate::ipc::set_local_stream_polling(stream, false) - }) - } - // A peer can disconnect after the caller's running check but before - // setsockopt. macOS reports that closed-socket race as EINVAL. - Err(err) if err.kind() == io::ErrorKind::InvalidInput => Ok(None), - Err(err) => Err(err), - } -} - -fn finish_timed_read( - result: std::io::Result>, - reset: impl FnOnce() -> std::io::Result<()>, -) -> std::io::Result> { - match result { - // None is terminal for this dedicated stream. macOS returns EINVAL when - // socket options are restored after the peer has already disconnected. - Ok(None) => Ok(None), - Ok(value) => { - reset()?; - Ok(value) - } - Err(err) => { - let _ = reset(); - Err(err) - } - } -} - -fn ensure_before_deadlines( - idle_deadline: Option, - total_deadline: Option, - message: &str, -) -> std::io::Result<()> { - let now = Instant::now(); - if idle_deadline.is_some_and(|deadline| now >= deadline) - || total_deadline.is_some_and(|deadline| now >= deadline) - { - return Err(io::Error::new(io::ErrorKind::TimedOut, message)); - } - Ok(()) -} - -fn sleep_until_poll( - idle_deadline: Option, - total_deadline: Option, - poll_interval: Duration, -) { - let now = Instant::now(); - let until_deadline = [idle_deadline, total_deadline] - .into_iter() - .flatten() - .filter_map(|deadline| deadline.checked_duration_since(now)) - .min() - .unwrap_or(poll_interval); - std::thread::sleep(poll_interval.min(until_deadline)); -} - -fn read_should_retry(err: &io::Error) -> bool { - matches!( - err.kind(), - io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::api::schema::{ErrorResponse, Method, ResponseResult, SuccessResponse}; - use crate::api::ApiRequestMessage; - #[cfg(unix)] - use crate::api::EventHub; - use crate::ipc::LocalStream; - use interprocess::local_socket::traits::Listener as _; - use std::io::{BufRead, BufReader, Write}; - use std::path::PathBuf; - use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; - use std::sync::Arc; - use std::time::{Duration, Instant}; - use tokio::sync::mpsc; - - static NEXT_LOCAL_STREAM_ID: AtomicU64 = AtomicU64::new(1); - - fn local_stream_pair(_name: &str) -> (LocalStream, LocalStream, PathBuf) { - let unique = format!( - "hpg-{}-{}.sock", - std::process::id(), - NEXT_LOCAL_STREAM_ID.fetch_add(1, Ordering::Relaxed) - ); - let path = std::env::temp_dir().join(unique); - let listener = crate::ipc::bind_local_listener(&path).unwrap(); - let client = crate::ipc::connect_local_stream(&path).unwrap(); - let server = listener.accept().unwrap(); - (client, server, path) - } - - fn read_response_line(stream: &mut LocalStream) -> String { - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - line - } - - #[cfg(unix)] - fn assert_server_stream_owner(owner: &str) { - assert!(owner.starts_with("pane.graphics.stream:")); - } - - fn respond_ok(message: ApiRequestMessage) { - let response = serde_json::to_string(&SuccessResponse { - id: message.request.id, - result: ResponseResult::Ok {}, - }) - .unwrap(); - message.respond_to.send(response).unwrap(); - } - - fn assert_close_and_respond(message: ApiRequestMessage, pane_id: &str, owner: &str) { - match &message.request.method { - Method::PaneGraphicsStreamClose(params) => { - assert_eq!(params.pane_id, pane_id); - assert_eq!(params.owner, owner); - } - other => panic!("unexpected close request: {other:?}"), - } - respond_ok(message); - } - - #[test] - fn browser_file_header_accepts_damage_but_keeps_full_canonical_frame() { - let header: FrameHeader = serde_json::from_str( - r#"{"format":"rgba","image_width":2,"image_height":3,"sequence":7,"revision":8,"file":{"path":"/private/frame"},"damage":{"x":1,"y":1,"width":1,"height":1},"transport":"direct-kitty","placement":{"grid_cols":2,"grid_rows":3}}"#, - ) - .unwrap(); - assert_eq!(header.data_length, None); - assert_eq!(header.file.unwrap().path, "/private/frame"); - assert_eq!((header.sequence, header.revision), (7, 8)); - assert_eq!((header.image_width, header.image_height), (2, 3)); - assert_eq!( - (header.placement.grid_cols, header.placement.grid_rows), - (2, 3) - ); - } - - #[test] - fn timed_read_skips_reset_after_stream_ends() { - let mut reset_called = false; - let result = finish_timed_read::<()>(Ok(None), || { - reset_called = true; - Ok(()) - }); - - assert!(result.unwrap().is_none()); - assert!(!reset_called); - } - - #[cfg(unix)] - #[test] - fn pane_graphics_stream_dispatches_binary_frames() { - let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); - let (mut client, server, _path) = local_stream_pair("api-pane-graphics-stream"); - client - .write_all(br#"{"id":"stream_1","method":"pane.graphics.stream","params":{"pane_id":"pane_1"}}"#) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let server_thread = std::thread::spawn(move || { - super::super::handle_connection(server, &api_tx, &event_hub, &server_running, None) - }); - - let open = api_rx.blocking_recv().unwrap(); - let stream_owner = match &open.request.method { - Method::PaneGraphicsStreamOpen(params) => { - assert_eq!(params.pane_id, "pane_1"); - assert_server_stream_owner(¶ms.owner); - assert_ne!(params.owner, "stream_1"); - params.owner.clone() - } - other => panic!("unexpected open request: {other:?}"), - }; - respond_ok(open); - - let ack: SuccessResponse = serde_json::from_str(&read_response_line(&mut client)).unwrap(); - assert_eq!(ack.id, "stream_1"); - assert_eq!(ack.result, ResponseResult::Ok {}); - - client - .write_all( - br#"{"format":"png","image_width":2,"image_height":1,"data_length":4,"placement":{"grid_cols":10,"grid_rows":5}}"#, - ) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.write_all(&[1_u8, 2, 3, 4]).unwrap(); - client.flush().unwrap(); - - let msg = api_rx.blocking_recv().unwrap(); - match &msg.request.method { - Method::PaneGraphicsStreamSet(params) => { - assert_eq!(params.pane_id, "pane_1"); - assert_eq!(params.owner, stream_owner); - assert_eq!(params.image_width, 2); - assert_eq!(params.image_height, 1); - assert_eq!(params.data.as_deref(), Some(&[1_u8, 2, 3, 4][..])); - assert!(params.data_base64.is_empty()); - assert_eq!(params.placement.grid_cols, 10); - assert_eq!(params.placement.grid_rows, 5); - } - other => panic!("unexpected request: {other:?}"), - } - respond_ok(msg); - - drop(client); - running.store(false, Ordering::Relaxed); - assert_close_and_respond(api_rx.blocking_recv().unwrap(), "pane_1", &stream_owner); - assert!(server_thread.join().unwrap().is_ok()); - } - - #[cfg(unix)] - #[test] - fn pane_graphics_stream_reports_open_errors_before_ack() { - let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); - let (mut client, server, _path) = local_stream_pair("api-pane-graphics-stream-error"); - client - .write_all(br#"{"id":"stream_2","method":"pane.graphics.stream","params":{"pane_id":"pane_1"}}"#) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let server_thread = std::thread::spawn(move || { - super::super::handle_connection(server, &api_tx, &event_hub, &server_running, None) - }); - - let open = api_rx.blocking_recv().unwrap(); - let stream_owner = match &open.request.method { - Method::PaneGraphicsStreamOpen(params) => { - assert_eq!(params.pane_id, "pane_1"); - assert_server_stream_owner(¶ms.owner); - assert_ne!(params.owner, "stream_2"); - params.owner.clone() - } - other => panic!("unexpected open request: {other:?}"), - }; - open.respond_to - .send(super::super::error_response_json( - open.request.id, - "feature_disabled", - "pane graphics are disabled by terminal.kitty_graphics".into(), - )) - .unwrap(); - - let response: ErrorResponse = - serde_json::from_str(&read_response_line(&mut client)).unwrap(); - assert_eq!(response.id, "stream_2"); - assert_eq!(response.error.code, "feature_disabled"); - - assert_close_and_respond(api_rx.blocking_recv().unwrap(), "pane_1", &stream_owner); - - drop(client); - running.store(false, Ordering::Relaxed); - assert!(server_thread.join().unwrap().is_ok()); - } - - #[cfg(unix)] - #[test] - fn pane_graphics_stream_closes_claim_after_open_timeout() { - let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); - let (mut client, server, _path) = local_stream_pair("api-pane-graphics-stream-timeout"); - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let server_thread = std::thread::spawn(move || { - serve_with_open_timeout( - server, - "stream_timeout".into(), - PaneGraphicsStreamParams { - pane_id: "pane_1".into(), - layer_id: None, - z_index: 0, - owner: String::new(), - }, - &api_tx, - &server_running, - Duration::from_millis(10), - ) - }); - - let open = api_rx.blocking_recv().unwrap(); - let stream_owner = match &open.request.method { - Method::PaneGraphicsStreamOpen(params) => { - assert_eq!(params.pane_id, "pane_1"); - assert_server_stream_owner(¶ms.owner); - assert_ne!(params.owner, "stream_timeout"); - params.owner.clone() - } - other => panic!("unexpected open request: {other:?}"), - }; - - let response: ErrorResponse = - serde_json::from_str(&read_response_line(&mut client)).unwrap(); - assert_eq!(response.id, "stream_timeout"); - assert_eq!(response.error.code, "server_unavailable"); - assert!(response.error.message.contains("timed out")); - - assert_close_and_respond(api_rx.blocking_recv().unwrap(), "pane_1", &stream_owner); - - drop(open); - drop(client); - running.store(false, Ordering::Relaxed); - assert!(server_thread.join().unwrap().is_ok()); - } - - #[cfg(unix)] - #[test] - fn pane_graphics_stream_closes_claim_when_client_disconnects_before_ack() { - let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); - let (mut client, server, _path) = - local_stream_pair("api-pane-graphics-stream-ack-disconnect"); - client - .write_all(br#"{"id":"stream_3","method":"pane.graphics.stream","params":{"pane_id":"pane_1"}}"#) - .unwrap(); - client.write_all(b"\n").unwrap(); - client.flush().unwrap(); - - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let event_hub = EventHub::default(); - let server_thread = std::thread::spawn(move || { - super::super::handle_connection(server, &api_tx, &event_hub, &server_running, None) - }); - - let open = api_rx.blocking_recv().unwrap(); - let stream_owner = match &open.request.method { - Method::PaneGraphicsStreamOpen(params) => { - assert_eq!(params.pane_id, "pane_1"); - assert_server_stream_owner(¶ms.owner); - assert_ne!(params.owner, "stream_3"); - params.owner.clone() - } - other => panic!("unexpected open request: {other:?}"), - }; - - drop(client); - respond_ok(open); - - let (close_tx, close_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - close_tx.send(api_rx.blocking_recv()).unwrap(); - }); - let close = close_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap() - .unwrap(); - assert_close_and_respond(close, "pane_1", &stream_owner); - - running.store(false, Ordering::Relaxed); - assert!(server_thread.join().unwrap().is_ok()); - } - - #[test] - fn idle_graphics_stream_waits_for_header_without_timing_out() { - let (_client, mut server, _path) = local_stream_pair("graphics-idle-header"); - let running = Arc::new(AtomicBool::new(true)); - let active = Arc::new(AtomicBool::new(true)); - let stop = Arc::clone(&running); - let stopper = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(30)); - stop.store(false, Ordering::Relaxed); - }); - - let result = read_line( - &mut server, - &running, - &active, - MAX_STREAM_FRAME_HEADER_BYTES, - Duration::from_millis(10), - Duration::from_millis(20), - ) - .unwrap(); - - assert!(result.is_none()); - stopper.join().unwrap(); - } - - #[test] - fn fallback_poll_backoff_preserves_fast_window_then_reaches_poll_ceiling() { - let mut backoff = PollBackoff::new(); - for _ in 0..STREAM_FALLBACK_FAST_POLLS { - backoff.advance(); - assert_eq!(backoff.interval, STREAM_FALLBACK_POLL_INTERVAL); - } - - backoff.advance(); - assert_eq!(backoff.interval, Duration::from_millis(2)); - for _ in 0..6 { - backoff.advance(); - } - assert_eq!(backoff.interval, CONNECTION_POLL_INTERVAL); - - backoff.reset(); - assert_eq!(backoff.interval, STREAM_FALLBACK_POLL_INTERVAL); - assert_eq!(backoff.fast_polls_remaining, STREAM_FALLBACK_FAST_POLLS); - } - - #[test] - fn partial_graphics_header_times_out_after_first_byte() { - let (mut client, mut server, _path) = local_stream_pair("graphics-partial-header"); - client.write_all(b"{").unwrap(); - client.flush().unwrap(); - let running = Arc::new(AtomicBool::new(true)); - let active = Arc::new(AtomicBool::new(true)); - - let error = read_line( - &mut server, - &running, - &active, - MAX_STREAM_FRAME_HEADER_BYTES, - Duration::from_millis(20), - Duration::from_millis(100), - ) - .unwrap_err(); - - assert_eq!(error.kind(), io::ErrorKind::TimedOut); - } - - #[test] - fn trickled_graphics_body_obeys_absolute_deadline() { - let (mut client, mut server, _path) = local_stream_pair("graphics-trickle-body"); - client.write_all(&[1_u8]).unwrap(); - client.flush().unwrap(); - let running = Arc::new(AtomicBool::new(true)); - let active = Arc::new(AtomicBool::new(true)); - let writer_running = Arc::clone(&running); - let writer = std::thread::spawn(move || { - while writer_running.load(Ordering::Relaxed) { - if client.write_all(&[1_u8]).is_err() { - break; - } - let _ = client.flush(); - std::thread::sleep(Duration::from_millis(5)); - } - }); - - let started = Instant::now(); - let error = read_exact( - &mut server, - 1024, - &running, - &active, - Duration::from_millis(20), - Duration::from_millis(60), - ) - .unwrap_err(); - - running.store(false, Ordering::Relaxed); - writer.join().unwrap(); - assert_eq!(error.kind(), io::ErrorKind::TimedOut); - assert!(started.elapsed() >= Duration::from_millis(50)); - } - - #[test] - fn timed_out_header_dispatches_owner_scoped_stream_close() { - let (mut client, server, _path) = local_stream_pair("graphics-timeout-close"); - let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let server_thread = std::thread::spawn(move || { - serve_with_timeouts( - server, - "stream-timeout".into(), - PaneGraphicsStreamParams { - pane_id: "pane_1".into(), - layer_id: None, - z_index: 0, - owner: String::new(), - }, - &api_tx, - &server_running, - Duration::from_secs(1), - ReadTimeouts { - header_idle: Duration::from_millis(20), - header_total: Duration::from_millis(100), - body_idle: Duration::from_millis(20), - body_total: Duration::from_millis(100), - }, - ) - }); - - let open = api_rx.blocking_recv().unwrap(); - let owner = match &open.request.method { - Method::PaneGraphicsStreamOpen(params) => params.owner.clone(), - other => panic!("unexpected open request: {other:?}"), - }; - respond_ok(open); - let ack: SuccessResponse = serde_json::from_str(&read_response_line(&mut client)).unwrap(); - assert_eq!(ack.id, "stream-timeout"); - client.write_all(b"{").unwrap(); - client.flush().unwrap(); - - let (close_tx, close_rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || close_tx.send(api_rx.blocking_recv()).unwrap()); - let close = close_rx - .recv_timeout(Duration::from_secs(1)) - .unwrap() - .unwrap(); - assert_close_and_respond(close, "pane_1", &owner); - - let error = server_thread.join().unwrap().unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::TimedOut); - } - - #[test] - fn oversized_stream_frame_is_rejected_before_body_or_app_dispatch() { - let (mut client, mut server, _path) = local_stream_pair("graphics-oversized-frame"); - let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); - let running = Arc::new(AtomicBool::new(true)); - let server_running = Arc::clone(&running); - let stream_active = Arc::new(AtomicBool::new(true)); - let server_thread = std::thread::spawn(move || { - serve_frames( - &mut server, - "stream-oversized", - "owner-1", - "pane_1", - None, - 0, - &api_tx, - &server_running, - &stream_active, - READ_TIMEOUTS, - ) - }); - let header = serde_json::json!({ - "format": "png", - "image_width": 1, - "image_height": 1, - "data_length": crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + 1, - }); - client.write_all(format!("{header}\n").as_bytes()).unwrap(); - client.flush().unwrap(); - - let response: ErrorResponse = - serde_json::from_str(&read_response_line(&mut client)).unwrap(); - assert_eq!(response.error.code, "image_too_large"); - assert!(server_thread.join().unwrap().is_ok()); - assert!(api_rx.try_recv().is_err()); - } -} diff --git a/src/api/server/subscription_socket_tests.rs b/src/api/server/subscription_socket_tests.rs new file mode 100644 index 00000000..35b0bf12 --- /dev/null +++ b/src/api/server/subscription_socket_tests.rs @@ -0,0 +1,356 @@ +use super::*; +use crate::api::schema::{ + AgentStatus, EventData, EventEnvelope, EventKind, PaneInfo, PaneReadResult, ReadFormat, + ReadSource, +}; +use crate::ipc::{poll_local_stream_read_count, LocalStreamReadCount}; +use interprocess::local_socket::traits::Listener as _; +use serde_json::{json, Value}; +use std::sync::atomic::AtomicU64; +use tokio::sync::mpsc; + +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); + +struct SocketTest { + hub: EventHub, + running: Arc, + api_tx: ApiRequestSender, + api_rx: mpsc::UnboundedReceiver, + workers: Vec>>, + paths: Vec, +} + +impl SocketTest { + fn new() -> Self { + let (api_tx, api_rx) = mpsc::unbounded_channel(); + Self { + hub: EventHub::default(), + running: Arc::new(AtomicBool::new(true)), + api_tx, + api_rx, + workers: Vec::new(), + paths: Vec::new(), + } + } + + fn connect(&mut self) -> Client { + static NEXT_SOCKET: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "herdr-sub-{}-{}", + std::process::id(), + NEXT_SOCKET.fetch_add(1, Ordering::Relaxed) + )); + let listener = bind_local_listener(&path).unwrap(); + self.paths.push(path.clone()); + let mut stream = crate::ipc::connect_local_stream(&path).unwrap(); + let server = listener.accept().unwrap(); + set_local_stream_polling(&mut stream, true).unwrap(); + let api_tx = self.api_tx.clone(); + let hub = self.hub.clone(); + let running = Arc::clone(&self.running); + let worker = + std::thread::spawn(move || handle_connection(server, &api_tx, &hub, &running, None)); + self.workers.push(worker); + Client { + stream, + buffered: Vec::new(), + } + } + + fn app_request(&mut self) -> ApiRequestMessage { + let deadline = Instant::now() + RESPONSE_TIMEOUT; + loop { + match self.api_rx.try_recv() { + Ok(request) => return request, + Err(mpsc::error::TryRecvError::Empty) => { + assert!( + Instant::now() < deadline, + "timed out waiting for app request" + ); + std::thread::sleep(Duration::from_millis(1)); + } + Err(error) => panic!("app request channel closed: {error}"), + } + } + } +} + +impl Drop for SocketTest { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + self.api_rx.close(); + while self.api_rx.try_recv().is_ok() {} + let deadline = Instant::now() + APP_RESPONSE_TIMEOUT + Duration::from_secs(1); + let mut failures = Vec::new(); + for worker in self.workers.drain(..) { + while !worker.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + } + if worker.is_finished() { + match worker.join() { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(format!("connection failed: {error}")), + Err(_) => failures.push("connection panicked".into()), + } + } else { + failures.push("subscription connection did not stop".into()); + } + } + // Also remove Windows listener marker files, including after a setup failure. + for path in self.paths.drain(..) { + if let Err(error) = std::fs::remove_file(path) { + if error.kind() != io::ErrorKind::NotFound { + failures.push(format!("socket cleanup failed: {error}")); + } + } + } + if !std::thread::panicking() { + assert!(failures.is_empty(), "{failures:?}"); + } + } +} + +struct Client { + stream: LocalStream, + buffered: Vec, +} + +impl Client { + fn send(&mut self, request: Value) { + writeln!(self.stream, "{request}").unwrap(); + } + + fn subscribe(&mut self, id: &str, subscriptions: Value) { + self.send(json!({ + "id": id, + "method": "events.subscribe", + "params": {"subscriptions": subscriptions} + })); + } + + fn next_line(&mut self, deadline: Instant) -> Option { + // LocalStream recv timeouts are unsupported on Windows. Use the same bounded + // nonblocking/PeekNamedPipe reads as the API client, retaining partial JSON lines. + loop { + if let Some(end) = self.buffered.iter().position(|byte| *byte == b'\n') { + let line: Vec<_> = self.buffered.drain(..=end).collect(); + return Some(serde_json::from_slice(&line).expect("subscription JSON")); + } + assert!( + Instant::now() < deadline, + "timed out waiting for socket response" + ); + let mut bytes = [0; 4096]; + match poll_local_stream_read_count(&mut self.stream, &mut bytes).unwrap() { + LocalStreamReadCount::Data(count) => { + self.buffered.extend_from_slice(&bytes[..count]) + } + LocalStreamReadCount::Pending => std::thread::sleep(Duration::from_millis(1)), + LocalStreamReadCount::Closed => { + assert!(self.buffered.is_empty(), "incomplete JSON at EOF"); + return None; + } + } + } + } + + fn response(&mut self) -> Value { + self.next_line(Instant::now() + RESPONSE_TIMEOUT) + .expect("socket response before EOF") + } + + fn assert_started(&mut self, id: &str) { + let response = self.response(); + assert_eq!(response["id"], id); + assert_eq!(response["result"]["type"], "subscription_started"); + } + + fn assert_renames(&mut self, indices: std::ops::Range, deadline: Instant) { + for index in indices { + let event = self.next_line(deadline).expect("rename before EOF"); + assert_eq!(event["event"], "workspace_renamed"); + assert_eq!(event["data"]["label"], format!("flood-{index}")); + } + } + + fn assert_history_lost(&mut self, id: &str) { + let response = self.response(); + assert_eq!(response["id"], id); + assert_eq!(response["error"]["code"], "events_lost", "{response}"); + assert_eq!(self.next_line(Instant::now() + RESPONSE_TIMEOUT), None); + } +} + +fn renamed_event(index: usize) -> EventEnvelope { + EventEnvelope { + event: EventKind::WorkspaceRenamed, + data: EventData::WorkspaceRenamed { + workspace_id: "workspace_1".into(), + label: format!("flood-{index}"), + }, + } +} + +fn output_subscription() -> Value { + json!({ + "type": "pane.output_matched", + "pane_id": "pane_1", + "source": "recent", + "match": {"type": "substring", "value": "never"} + }) +} + +fn reply(request: ApiRequestMessage, result: ResponseResult) { + request + .respond_to + .send( + serde_json::to_string(&SuccessResponse { + id: request.request.id, + result, + }) + .unwrap(), + ) + .unwrap(); +} + +fn reply_to_probe(request: ApiRequestMessage) { + let result = match request.request.method { + Method::PaneGet(_) => ResponseResult::PaneInfo { + pane: PaneInfo { + pane_id: "pane_1".into(), + terminal_id: "term_1".into(), + workspace_id: "workspace_1".into(), + tab_id: "tab_1".into(), + focused: true, + cwd: None, + foreground_cwd: None, + restore_error: None, + label: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + display_agent: None, + agent_status: AgentStatus::Working, + state_labels: Default::default(), + tokens: Default::default(), + agent_session: None, + scroll: None, + revision: 0, + }, + }, + Method::PaneRead(_) => ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: "pane_1".into(), + workspace_id: "workspace_1".into(), + tab_id: "tab_1".into(), + source: ReadSource::RecentUnwrapped, + format: ReadFormat::Text, + text: String::new(), + revision: 0, + truncated: false, + }, + }, + ref other => panic!("unexpected subscription probe: {other:?}"), + }; + reply(request, result); +} + +#[test] +fn subscriptions_drain_retained_bursts_without_per_event_poll_delay() { + let mut test = SocketTest::new(); + let mut client = test.connect(); + client.subscribe("burst", json!([{"type": "workspace.renamed"}])); + client.assert_started("burst"); + for index in 0..128 { + test.hub.push(renamed_event(index)); + } + // One deadline for the entire batch detects a 100 ms delay per event. + client.assert_renames(0..128, Instant::now() + RESPONSE_TIMEOUT); +} + +#[test] +fn subscriptions_report_history_loss_before_sending_a_partial_stream() { + assert_subscription_history_loss(false); +} + +#[test] +fn subscriptions_report_history_loss_before_initial_agent_status() { + assert_subscription_history_loss(true); +} + +fn assert_subscription_history_loss(agent_status: bool) { + let mut test = SocketTest::new(); + let mut client = test.connect(); + let subscriptions = if agent_status { + json!([{ + "type": "pane.agent_status_changed", + "pane_id": "pane_1", + "agent_status": "working" + }]) + } else { + json!([{"type": "workspace.renamed"}, output_subscription()]) + }; + client.subscribe("history-gap", subscriptions); + // Hold the setup probe after the server pins its subscription cursor. + let probe = test.app_request(); + assert!(probe.request.id.ends_with(":probe")); + for index in 0..600 { + test.hub.push(renamed_event(index)); + } + reply_to_probe(probe); + client.assert_started("history-gap"); + client.assert_history_lost("history-gap"); +} + +#[test] +fn lagging_subscription_closes_without_interrupting_other_clients() { + let mut test = SocketTest::new(); + let mut healthy = test.connect(); + healthy.subscribe("healthy", json!([{"type": "workspace.renamed"}])); + healthy.assert_started("healthy"); + + let mut slow = test.connect(); + slow.subscribe( + "slow", + json!([{"type": "workspace.renamed"}, output_subscription()]), + ); + let probe = test.app_request(); + assert_eq!(probe.request.id, "slow:sub:1:probe"); + reply_to_probe(probe); + slow.assert_started("slow"); + // Pause only this connection in an existing app request, rather than depending + // on OS socket buffer sizes or sleeping to make its event cursor fall behind. + let paused_read = test.app_request(); + assert_eq!(paused_read.request.id, "slow:sub:1:read"); + assert!(matches!(paused_read.request.method, Method::PaneRead(_))); + // All five batches must finish before the held app request can time out. + let deadline = Instant::now() + RESPONSE_TIMEOUT; + for batch in 0..5 { + let indices = batch * 128..(batch + 1) * 128; + for index in indices.clone() { + test.hub.push(renamed_event(index)); + } + healthy.assert_renames(indices, deadline); + } + + reply_to_probe(paused_read); + slow.assert_history_lost("slow"); + test.hub.push(renamed_event(640)); + healthy.assert_renames(640..641, Instant::now() + RESPONSE_TIMEOUT); + + let mut ordinary = test.connect(); + ordinary.send(json!({"id": "ordinary", "method": "workspace.list", "params": {}})); + let request = test.app_request(); + assert_eq!(request.request.id, "ordinary"); + assert!(matches!(request.request.method, Method::WorkspaceList(_))); + reply( + request, + ResponseResult::WorkspaceList { + workspaces: Vec::new(), + }, + ); + let response = ordinary.response(); + assert_eq!(response["id"], "ordinary"); + assert_eq!(response["result"]["type"], "workspace_list"); +} diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 0888250e..2258a3ce 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -1,5 +1,6 @@ use regex::Regex; +use crate::api::event_hub::EventHistoryError; use crate::api::schema::{ ErrorBody, ErrorResponse, EventKind, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, PaneScrollChangedEvent, PaneScrollInfo, Request, Subscription, @@ -277,6 +278,72 @@ impl ActiveSubscription { _ => Ok(self.poll(api_tx, event_hub)), } } + + pub(super) fn poll_batch( + &mut self, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + ) -> Result, ErrorBody> { + match self { + Self::Event(subscription) => { + let events = subscription_events_after(event_hub, subscription.last_sequence)?; + let mut matching = Vec::new(); + for (sequence, event) in events { + subscription.last_sequence = sequence; + if event.event == subscription.event_kind { + matching.push(serde_json::to_value(event).map_err(event_encoding_error)?); + } + } + Ok(matching) + } + Self::AgentStatusChanged(subscription) => { + let events = subscription_events_after(event_hub, subscription.last_sequence)?; + let mut matching = Vec::new(); + for (sequence, event) in events { + subscription.last_sequence = sequence; + if let Some(event) = subscription.event_from_history(event) { + matching.push(serde_json::to_value(event).map_err(event_encoding_error)?); + } + } + if matching.is_empty() { + if let Some(event) = + subscription.poll_snapshot(api_tx, event_hub).ok().flatten() + { + matching.push(serde_json::to_value(event).map_err(event_encoding_error)?); + } + } + Ok(matching) + } + // These subscriptions sample current state, not retained event history. + // Keep their existing cadence even when a lifecycle batch was nonempty. + Self::OutputMatched(_) | Self::ScrollChanged(_) => { + Ok(self.poll(api_tx, event_hub).into_iter().collect()) + } + } + } +} + +fn subscription_events_after( + event_hub: &EventHub, + sequence: u64, +) -> Result, ErrorBody> { + event_hub.events_after_checked(sequence).map_err(|error| match error { + EventHistoryError::Lost => ErrorBody { + code: "events_lost".into(), + message: "event subscription fell behind retained history; resubscribe and resync with session.snapshot".into(), + }, + EventHistoryError::Unavailable => ErrorBody { + code: "server_unavailable".into(), + message: "event history is unavailable".into(), + }, + }) +} + +fn event_encoding_error(error: serde_json::Error) -> ErrorBody { + ErrorBody { + code: "internal_error".into(), + message: format!("failed to encode subscription event: {error}"), + } } impl ActiveEventSubscription { @@ -341,10 +408,55 @@ impl ActiveAgentStatusChangedSubscription { api_tx: &ApiRequestSender, event_hub: &EventHub, ) -> Result, ErrorResponse> { - let mut saw_status_event = false; for (sequence, event) in event_hub.events_after(self.last_sequence) { self.last_sequence = sequence; - let crate::api::schema::EventData::PaneAgentStatusChanged { + if let Some(event) = self.event_from_history(event) { + return Ok(Some(event)); + } + } + + self.poll_snapshot(api_tx, event_hub) + } + + fn event_from_history( + &mut self, + event: crate::api::schema::EventEnvelope, + ) -> Option { + if event.event != EventKind::PaneAgentStatusChanged { + return None; + } + let crate::api::schema::EventData::PaneAgentStatusChanged { + pane_id, + workspace_id, + agent_status, + agent, + title, + display_agent, + state_labels, + } = event.data + else { + return None; + }; + if pane_id != self.pane_id { + return None; + } + self.last_status = Some(agent_status); + self.last_presentation = Some(PanePresentationSnapshot::from_event( + &title, + &display_agent, + &state_labels, + )); + self.initial_event = None; + if self + .status_filter + .is_some_and(|wanted| wanted != agent_status) + { + return None; + } + + Some(SubscriptionEventEnvelope { + event: SubscriptionEventKind::PaneAgentStatusChanged, + data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent { pane_id, workspace_id, agent_status, @@ -352,47 +464,16 @@ impl ActiveAgentStatusChangedSubscription { title, display_agent, state_labels, - } = event.data - else { - continue; - }; - if event.event != crate::api::schema::EventKind::PaneAgentStatusChanged { - continue; - } - if pane_id != self.pane_id { - continue; - } - saw_status_event = true; - - let current_presentation = - PanePresentationSnapshot::from_event(&title, &display_agent, &state_labels); - self.last_status = Some(agent_status); - self.last_presentation = Some(current_presentation); - if self - .status_filter - .is_some_and(|wanted| wanted != agent_status) - { - continue; - } - - self.initial_event = None; - return Ok(Some(SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneAgentStatusChanged, - data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent { - pane_id, - workspace_id, - agent_status, - agent, - title, - display_agent, - state_labels, - }), - })); - } + }), + }) + } - if saw_status_event { - self.initial_event = None; - } else if event_hub.current_sequence() != self.last_sequence { + fn poll_snapshot( + &mut self, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + ) -> Result, ErrorResponse> { + if event_hub.current_sequence() != self.last_sequence { return Ok(None); } else if let Some(event) = self.initial_event.take() { return Ok(Some(SubscriptionEventEnvelope { @@ -620,6 +701,7 @@ mod tests { focused: true, cwd: None, foreground_cwd: None, + restore_error: None, label: None, agent: None, title: None, @@ -687,6 +769,96 @@ mod tests { )); } + #[test] + fn lifecycle_batch_drains_in_order_and_advances_past_unmatched_events() { + let event_hub = EventHub::default(); + event_hub.push(workspace_focused_event("old")); + let start = event_hub.current_sequence(); + event_hub.push(workspace_focused_event("setup")); + let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut subscription = ActiveSubscription::new( + Subscription::WorkspaceFocused {}, + "batch", + 0, + &api_tx, + &event_hub, + start, + ) + .unwrap(); + event_hub.push(presentation_event(None)); + event_hub.push(workspace_focused_event("live")); + let events = subscription.poll_batch(&api_tx, &event_hub).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["data"]["workspace_id"], "setup"); + assert_eq!(events[1]["data"]["workspace_id"], "live"); + assert!(subscription + .poll_batch(&api_tx, &event_hub) + .unwrap() + .is_empty()); + let ActiveSubscription::Event(subscription) = subscription else { + panic!("expected lifecycle subscription"); + }; + assert_eq!(subscription.last_sequence, event_hub.current_sequence()); + } + + #[test] + fn agent_status_batch_preserves_transitions_filters_and_initial_state_ordering() { + for filtered in [false, true] { + let event_hub = EventHub::default(); + let mut subscription = ActiveSubscription::AgentStatusChanged(Box::new( + ActiveAgentStatusChangedSubscription { + pane_id: "pane_1".into(), + status_filter: filtered.then_some(AgentStatus::Working), + last_status: Some(AgentStatus::Working), + last_presentation: None, + last_sequence: event_hub.current_sequence(), + initial_event: Some(PaneAgentStatusChangedEvent { + pane_id: "pane_1".into(), + workspace_id: "workspace_1".into(), + agent_status: AgentStatus::Working, + agent: Some("pi".into()), + title: Some("stale initial snapshot".into()), + display_agent: None, + state_labels: HashMap::new(), + }), + request_prefix: "batch".into(), + }, + )); + for (status, title) in [ + (AgentStatus::Working, "started"), + (AgentStatus::Blocked, "approval"), + (AgentStatus::Idle, "finished"), + (AgentStatus::Working, "restarted"), + ] { + let mut event = presentation_event(Some(title)); + let EventData::PaneAgentStatusChanged { agent_status, .. } = &mut event.data else { + panic!("expected status data"); + }; + *agent_status = status; + event_hub.push(event); + } + let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel(); + let events = subscription.poll_batch(&api_tx, &event_hub).unwrap(); + let titles = events + .iter() + .map(|event| event["data"]["title"].as_str().unwrap()) + .collect::>(); + assert_eq!( + titles, + if filtered { + vec!["started", "restarted"] + } else { + vec!["started", "approval", "finished", "restarted"] + } + ); + let ActiveSubscription::AgentStatusChanged(subscription) = subscription else { + panic!("expected agent subscription"); + }; + assert_eq!(subscription.last_sequence, event_hub.current_sequence()); + assert!(subscription.initial_event.is_none()); + } + } + #[test] fn scroll_subscription_emits_when_scroll_snapshot_changes() { let at_bottom = PaneScrollInfo { diff --git a/src/app/actions.rs b/src/app/actions.rs index 85314331..e1ae2ffc 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -25,12 +25,7 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS } fn is_completion_transition(change: &EffectiveStateChange) -> bool { - is_completion_transition_parts( - change.previous_state, - change.state, - change.previous_agent_label.as_deref(), - change.agent_label.as_deref(), - ) + is_background_completion_transition(change.previous_state, change.state) } fn public_tab_id_for_index(ws: &crate::workspace::Workspace, tab_idx: usize) -> Option { @@ -40,19 +35,6 @@ fn public_tab_id_for_index(ws: &crate::workspace::Workspace, tab_idx: usize) -> )) } -pub fn is_completion_transition_parts( - previous_state: AgentState, - state: AgentState, - previous_agent_label: Option<&str>, - agent_label: Option<&str>, -) -> bool { - is_background_completion_transition(previous_state, state) - || (previous_state == AgentState::Unknown - && state == AgentState::Idle - && previous_agent_label.is_some() - && previous_agent_label == agent_label) -} - pub fn active_tab_suppresses_notifications( is_active_tab: bool, outer_terminal_focus: Option, @@ -60,7 +42,6 @@ pub fn active_tab_suppresses_notifications( is_active_tab && outer_terminal_focus != Some(false) } -#[cfg(test)] pub fn notification_sound_for_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, @@ -82,58 +63,21 @@ pub fn notification_sound_for_state_change( } } -pub fn notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications: bool, - prev_state: AgentState, - new_state: AgentState, - previous_agent_label: Option<&str>, - agent_label: Option<&str>, -) -> Option { - if new_state == prev_state { - return None; - } - - match new_state { - AgentState::Blocked => Some(crate::sound::Sound::Request), - AgentState::Idle - if is_completion_transition_parts( - prev_state, - new_state, - previous_agent_label, - agent_label, - ) && !suppress_active_tab_notifications => - { - Some(crate::sound::Sound::Done) - } - _ => None, - } -} - fn notification_sound_for_effective_state_change( suppress_active_tab_notifications: bool, change: &EffectiveStateChange, ) -> Option { - if change.state == change.previous_state { - return None; - } - - match change.state { - AgentState::Blocked => Some(crate::sound::Sound::Request), - AgentState::Idle - if is_completion_transition(change) && !suppress_active_tab_notifications => - { - Some(crate::sound::Sound::Done) - } - _ => None, - } + notification_sound_for_state_change( + suppress_active_tab_notifications, + change.previous_state, + change.state, + ) } -pub fn notification_toast_for_state_change_with_agent_labels( +pub fn notification_toast_for_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, - previous_agent_label: Option<&str>, - agent_label: Option<&str>, ) -> Option { if suppress_active_tab_notifications || new_state == prev_state { return None; @@ -141,14 +85,7 @@ pub fn notification_toast_for_state_change_with_agent_labels( match new_state { AgentState::Blocked => Some(ToastKind::NeedsAttention), - AgentState::Idle - if is_completion_transition_parts( - prev_state, - new_state, - previous_agent_label, - agent_label, - ) => - { + AgentState::Idle if is_background_completion_transition(prev_state, new_state) => { Some(ToastKind::Finished) } _ => None, @@ -159,15 +96,11 @@ fn notification_toast_for_effective_state_change( suppress_active_tab_notifications: bool, change: &EffectiveStateChange, ) -> Option { - if suppress_active_tab_notifications || change.state == change.previous_state { - return None; - } - - match change.state { - AgentState::Blocked => Some(ToastKind::NeedsAttention), - AgentState::Idle if is_completion_transition(change) => Some(ToastKind::Finished), - _ => None, - } + notification_toast_for_state_change( + suppress_active_tab_notifications, + change.previous_state, + change.state, + ) } pub fn notification_toast_for_pane_state_update( @@ -181,12 +114,10 @@ pub fn notification_toast_for_pane_state_update( return None; } - notification_toast_for_state_change_with_agent_labels( + notification_toast_for_state_change( suppress_active_tab_notifications, update.previous_state, update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), ) } @@ -1511,6 +1442,12 @@ impl AppState { }) .into_iter() .collect(), + AppEvent::CodexPromptObserved { pane_id, ready } => self + .update_terminal_state(pane_id, |terminal| { + terminal.observe_codex_prompt_ready(ready) + }) + .into_iter() + .collect(), AppEvent::StateChanged { pane_id, agent, @@ -1563,6 +1500,28 @@ impl AppState { .collect() } } + AppEvent::AgentResumeReported { + pane_id, + source, + agent_label, + seq, + argv, + } => self + .update_terminal_state(pane_id, |terminal| { + terminal.record_reported_resume(&source, &agent_label, seq, argv); + None + }) + .into_iter() + .collect(), + AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at, + } => self + .update_terminal_state(pane_id, |terminal| { + terminal.clear_self_reported_agent(observed_at) + }) + .into_iter() + .collect(), AppEvent::AgentSessionReported { pane_id, source, @@ -1673,6 +1632,7 @@ impl AppState { } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), + AppEvent::WorktreeReadFinished(_) => Vec::new(), AppEvent::TabBarCommandFinished { .. } => Vec::new(), AppEvent::PluginCommandFinished { .. } => Vec::new(), } @@ -1709,39 +1669,62 @@ impl AppState { managed_changed, agent_name_changed, unchanged_change, - managed_launch_pending, suppress_acquisition_completion, + completion_reset, ) = { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); - let managed_launch_pending = terminal.managed_agent_launch_pending(); - let mutation = update(terminal)?; + let had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen; + let resume_revision = terminal.reported_resume_revision(); + let mutation = update(terminal); + terminal.reconcile_reported_resume(); + // Resume-only changes return no mutation but must still be saved. + if terminal.reported_resume_revision() != resume_revision { + self.session_dirty = true; + } + let mutation = mutation?; + let completion_reset = mutation.session_ref_changed + || mutation + .effective_state_change + .as_ref() + .is_some_and(|change| change.previous_agent_label != change.agent_label); + if completion_reset { + terminal.last_agent_completion_seq = None; + } let managed_changed = terminal.reconcile_managed_agent_at(now, false); let suppress_acquisition_completion = terminal.finish_agent_process_acquisition(); let agent_name_changed = terminal.agent_name != previous_agent_name; - let unchanged_change = (mutation.agent_released || agent_name_changed) + let unchanged_change = (mutation.agent_released + || agent_name_changed + || (completion_reset && had_completion)) .then(|| terminal.unchanged_effective_state_change_at(now)); ( mutation, managed_changed, agent_name_changed, unchanged_change, - managed_launch_pending, suppress_acquisition_completion, + completion_reset, ) }; + if completion_reset { + self.pending_agent_notifications.remove(&pane_id); + self.workspaces[ws_idx].pane_state_mut(pane_id)?.seen = true; + } if mutation.session_ref_changed || managed_changed || agent_name_changed { self.mark_session_dirty(); } let agent_released = mutation.agent_released; let change = mutation.effective_state_change.or(unchanged_change)?; let suppress_completion = force_suppress_completion - || (change.state == AgentState::Idle - && (managed_launch_pending || suppress_acquisition_completion)); + || (change.state == AgentState::Idle && suppress_acquisition_completion); if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq); + terminal.last_agent_completion_seq = (!suppress_completion + && is_completion_transition(&change)) + .then_some(self.next_agent_state_change_seq); } } let seen = self.apply_pane_state_change(ws_idx, pane_id, &change, suppress_completion)?; @@ -2410,7 +2393,7 @@ mod tests { fn link_resolution_regions_keep_grapheme_byte_offsets() { let mut terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); terminal.write("e\u{301}(https://example.com/路e\u{301}),".as_bytes()); - let expected = vec![crate::api::schema::PaneLinkRegion { + let expected = vec![crate::ghostty::LinkRegion { row: 0, start_col: 2, end_col: 24, @@ -2438,7 +2421,7 @@ mod tests { .unwrap(); assert_eq!( regions, - vec![crate::api::schema::PaneLinkRegion { + vec![crate::ghostty::LinkRegion { row: 0, start_col: 0, end_col: 3 @@ -3054,34 +3037,253 @@ mod tests { assert!(pane.seen); } + fn assert_completion_guard_sequence( + acquired: bool, + states: &[AgentState], + expect_completion: bool, + ) { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + app.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.toast_config.delay_seconds = 0; + let pane_id = app.workspaces[1].tabs[0].root_pane; + if acquired { + app.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: Instant::now(), + }); + } + for &state in states { + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state, + visible_blocker: state == AgentState::Blocked, + visible_working: state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + let finished = app + .toast + .as_ref() + .is_some_and(|toast| toast.kind == ToastKind::Finished); + assert_eq!( + finished, expect_completion, + "completion notification: {states:?}" + ); + assert_eq!( + !app.workspaces[1].panes[&pane_id].seen, expect_completion, + "unseen completion: {states:?}" + ); + let terminal = &app.terminals[&app.workspaces[1].panes[&pane_id].attached_terminal_id]; + assert_eq!( + terminal.last_agent_completion_seq.is_some(), + expect_completion + ); + if expect_completion { + assert_eq!( + terminal.last_agent_completion_seq, + terminal.last_agent_state_change_seq + ); + } + app.assert_invariants_for_test(); + } + #[test] - fn idle_after_known_unknown_agent_in_background_marks_done() { - let mut state = app_with_workspaces(&["active", "background"]); - state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - state.active = Some(0); - let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + fn completion_guard_unknown_to_idle_is_not_completed_work() { + assert_completion_guard_sequence(false, &[AgentState::Unknown, AgentState::Idle], false); + } - state.handle_app_event(AppEvent::StateChanged { - pane_id: bg_pane_id, - agent: Some(Agent::Pi), + #[test] + fn completion_guard_first_work_finishes_without_prior_idle() { + assert_completion_guard_sequence(true, &[AgentState::Working, AgentState::Idle], true); + } + + #[test] + fn completion_guard_first_work_can_pause_for_permission() { + assert_completion_guard_sequence( + true, + &[AgentState::Working, AgentState::Blocked, AgentState::Idle], + true, + ); + } + + #[test] + fn completion_guard_startup_trust_is_not_completed_work() { + assert_completion_guard_sequence(true, &[AgentState::Blocked, AgentState::Idle], false); + } + + #[test] + fn completion_guard_managed_launch_readiness_does_not_swallow_work() { + for first_state in [AgentState::Blocked, AgentState::Working] { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + let pane_id = app.workspaces[1].tabs[0].root_pane; + let terminal_id = app.workspaces[1].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals + .get_mut(&terminal_id) + .unwrap() + .begin_managed_agent( + "worker".into(), + Agent::Pi, + Instant::now(), + std::time::Duration::ZERO, + std::time::Duration::from_secs(60), + ); + for state in [first_state, AgentState::Idle] { + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state, + visible_blocker: state == AgentState::Blocked, + visible_working: state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + let terminal = &app.terminals[&terminal_id]; + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!( + terminal.last_agent_completion_seq.is_some(), + first_state == AgentState::Working + ); + assert_eq!( + !app.workspaces[1].panes[&pane_id].seen, + first_state == AgentState::Working + ); + } + } + + #[test] + fn codex_prompt_observation_changes_readiness_without_completing_a_turn() { + let mut app = app_with_workspaces(&["active", "background"]); + let pane_id = app.workspaces[1].tabs[0].root_pane; + let terminal_id = app.workspaces[1].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals + .get_mut(&terminal_id) + .unwrap() + .begin_managed_agent( + "reviewer".into(), + Agent::Codex, + Instant::now(), + std::time::Duration::ZERO, + std::time::Duration::from_secs(60), + ); + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), state: AgentState::Unknown, visible_blocker: false, visible_working: false, process_exited: false, - observed_at: std::time::Instant::now(), + observed_at: Instant::now(), }); - state.handle_app_event(AppEvent::StateChanged { - pane_id: bg_pane_id, - agent: Some(Agent::Pi), - state: AgentState::Idle, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: std::time::Instant::now(), + app.handle_app_event(AppEvent::CodexPromptObserved { + pane_id, + ready: true, }); + let terminal = &app.terminals[&terminal_id]; + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.state, AgentState::Unknown); + assert!(terminal.last_agent_completion_seq.is_none()); + } - let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); - assert!(!pane.seen); + #[test] + fn completion_guard_same_state_agent_replacement_clears_old_work() { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + app.toast_config.delay_seconds = 5; + let pane_id = app.workspaces[1].tabs[0].root_pane; + for (seq, label, state) in [ + (1, "old", AgentState::Working), + (2, "old", AgentState::Idle), + (3, "new", AgentState::Idle), + ] { + app.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "custom:worker".into(), + agent_label: label.into(), + state, + message: None, + seq: Some(seq), + session_ref: None, + }); + if seq == 2 { + assert!(!app.workspaces[1].panes[&pane_id].seen); + assert!(app.pending_agent_notifications.contains_key(&pane_id)); + } + } + let terminal = &app.terminals[&app.workspaces[1].panes[&pane_id].attached_terminal_id]; + assert_eq!(terminal.effective_agent_label(), Some("new")); + assert!(terminal.last_agent_completion_seq.is_none()); + assert!(app.workspaces[1].panes[&pane_id].seen); + assert!(!app.pending_agent_notifications.contains_key(&pane_id)); + } + + #[test] + fn completion_guard_idle_session_replacement_clears_seen_and_pending_delivery() { + let mut app = app_with_workspaces(&["active", "background"]); + app.active = Some(0); + app.toast_config.delay_seconds = 5; + let pane_id = app.workspaces[1].tabs[0].root_pane; + for (seq, session, reason) in [(1, "old-session", "startup"), (2, "new-session", "clear")] { + let updates = app.handle_app_event(AppEvent::AgentSessionReported { + pane_id, + source: "herdr:claude".into(), + agent_label: "claude".into(), + seq: Some(seq), + session_ref: crate::agent_resume::AgentSessionRef::id(session), + session_start_source: Some(reason.into()), + }); + if seq == 1 { + for state in [AgentState::Working, AgentState::Idle] { + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Claude), + state, + visible_blocker: false, + visible_working: state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + assert!(!app.workspaces[1].panes[&pane_id].seen); + assert!(app.pending_agent_notifications.contains_key(&pane_id)); + } else { + assert!( + !updates.is_empty(), + "session replacement must publish attention reset" + ); + } + } + let terminal = &app.terminals[&app.workspaces[1].panes[&pane_id].attached_terminal_id]; + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .unwrap() + .session_ref + .value, + "new-session" + ); + assert!(terminal.last_agent_completion_seq.is_none()); + assert!(app.workspaces[1].panes[&pane_id].seen); + assert!(!app.pending_agent_notifications.contains_key(&pane_id)); + } + + #[test] + fn completion_guard_normal_turn_still_finishes() { + assert_completion_guard_sequence( + true, + &[AgentState::Idle, AgentState::Working, AgentState::Idle], + true, + ); } #[test] @@ -3139,9 +3341,9 @@ mod tests { .pop() .expect("idle state update"); - assert!(update.suppress_completion); - assert!(state.workspaces[1].panes[&pane_id].seen); - assert!(!matches!( + assert!(!update.suppress_completion); + assert!(!state.workspaces[1].panes[&pane_id].seen); + assert!(matches!( state.toast.as_ref().map(|toast| toast.kind), Some(ToastKind::Finished) )); @@ -3368,6 +3570,200 @@ mod tests { assert!(state.toast.is_none()); } + fn report_custom_agent_with_resume(state: &mut AppState, pane_id: PaneId, argv: &[&str]) { + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + state: AgentState::Idle, + message: None, + seq: Some(1), + session_ref: None, + }); + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + seq: Some(1), + argv: argv.iter().map(|part| part.to_string()).collect(), + }); + } + + fn first_pane_terminal(state: &AppState) -> (PaneId, crate::terminal::TerminalId) { + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0].panes[&pane_id] + .attached_terminal_id + .clone(); + (pane_id, terminal_id) + } + + #[test] + fn reported_resume_follows_the_reporting_agent_until_release() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + + let resume = state.terminals[&terminal_id].reported_resume().unwrap(); + assert_eq!(resume.agent, "prime-agent"); + assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]); + + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "someone-else".into(), + agent_label: "other".into(), + seq: None, + argv: vec!["other".into()], + }); + assert_eq!( + state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv, + vec!["prime-agent", "--resume", "a"] + ); + + state.handle_app_event(AppEvent::HookAgentReleased { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + known_agent: None, + seq: Some(2), + }); + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + } + + #[test] + fn reported_resume_is_dropped_and_saved_when_another_agent_takes_the_pane() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + state.session_dirty = false; + + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "custom:other".into(), + agent_label: "other".into(), + state: AgentState::Working, + message: None, + seq: None, + session_ref: None, + }); + + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + assert!(state.session_dirty); + } + + #[test] + fn reported_resume_of_recognized_agent_is_dropped_when_its_process_exits() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + seq: None, + argv: vec!["pi".into(), "--continue".into()], + }); + assert!(state.terminals[&terminal_id].reported_resume().is_some()); + state.session_dirty = false; + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: std::time::Instant::now(), + }); + + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + assert!(state.session_dirty); + + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + seq: None, + argv: vec!["pi".into(), "--continue".into()], + }); + assert!( + state.terminals[&terminal_id].reported_resume().is_none(), + "a late report must not revive an exited agent" + ); + } + + #[test] + fn shell_return_drops_self_reported_agent_and_its_resume() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + assert!(state.terminals[&terminal_id].self_reported_agent_active()); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: std::time::Instant::now(), + }); + + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.effective_agent_label(), None); + assert!(terminal.reported_resume().is_none()); + assert!(!terminal.self_reported_agent_active()); + } + + #[test] + fn delayed_shell_return_keeps_an_agent_that_claimed_the_pane_afterwards() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + let shell_seen_idle_at = std::time::Instant::now(); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "b"]); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: shell_seen_idle_at, + }); + + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.effective_agent_label(), Some("prime-agent")); + assert!(terminal.reported_resume().is_some()); + } + + #[test] + fn shell_return_keeps_agents_herdr_recognizes_by_process() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + state: AgentState::Working, + message: None, + seq: Some(1), + session_ref: None, + }); + assert!(!state.terminals[&terminal_id].self_reported_agent_active()); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: std::time::Instant::now(), + }); + + assert_eq!( + state.terminals[&terminal_id].effective_agent_label(), + Some("pi") + ); + } + #[test] fn hook_reported_unknown_agent_sets_toast_title_from_label() { let mut state = app_with_workspaces(&["active", "background"]); diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 2cbd93ab..f088138c 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -25,14 +25,19 @@ impl App { pub(crate) fn sync_pending_agent_resume_deadline(&mut self, now: Instant) { if !self.has_pending_agent_resumes() { self.pending_agent_resume_deadline = None; + self.next_agent_resume_at = None; return; } if self.pending_agent_resume_candidates().is_empty() { self.pending_agent_resume_deadline = None; return; } - self.pending_agent_resume_deadline - .get_or_insert(now + super::PENDING_AGENT_RESUME_THEME_WAIT); + if let Some(next) = self.next_agent_resume_at { + self.pending_agent_resume_deadline = Some(next); + } else { + self.pending_agent_resume_deadline + .get_or_insert(now + super::PENDING_AGENT_RESUME_THEME_WAIT); + } } pub(crate) fn pending_agent_resume_due(&self, now: Instant) -> bool { @@ -40,7 +45,15 @@ impl App { .is_some_and(|deadline| now >= deadline) } - pub(crate) fn start_pending_agent_resumes(&mut self, allow_empty_theme: bool) -> bool { + pub(crate) fn start_pending_agent_resumes( + &mut self, + now: Instant, + allow_empty_theme: bool, + ) -> bool { + // Geometry/theme events can also enter here; they must not bypass spacing. + if self.next_agent_resume_at.is_some_and(|next| now < next) { + return false; + } let pending = self.pending_agent_resume_candidates(); let mut changed = false; for PendingAgentResumeCandidate { @@ -64,6 +77,11 @@ impl App { cols, allow_empty_theme, ); + if changed && !self.startup_per_agent_delay.is_zero() { + self.next_agent_resume_at = Some(now + self.startup_per_agent_delay); + self.pending_agent_resume_deadline = self.next_agent_resume_at; + break; + } } if changed { @@ -72,6 +90,9 @@ impl App { if !self.has_pending_agent_resumes() || self.pending_agent_resume_candidates().is_empty() { self.pending_agent_resume_deadline = None; } + if !self.has_pending_agent_resumes() { + self.next_agent_resume_at = None; + } changed } @@ -233,6 +254,15 @@ impl App { return false; }; + if !cwd.is_dir() { + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.pending_agent_resume_plan = None; + terminal.restore_error = Some("Saved directory is unavailable. Restore the directory and restart this session.".into()); + terminal.revision = terminal.revision.saturating_add(1); + } + return true; + } + let runtime = match crate::terminal::TerminalRuntime::spawn( pane_id, rows, @@ -257,9 +287,11 @@ impl App { "failed to start shell for deferred agent resume" ); if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { - terminal.clear_agent_runtime_identity_after_respawn(); + terminal.pending_agent_resume_plan = None; + terminal.restore_error = Some(format!("Could not start the saved shell: {err}. Fix the shell configuration and restart this session.")); + terminal.revision = terminal.revision.saturating_add(1); } - return false; + return true; } }; @@ -364,6 +396,87 @@ mod tests { ) } + #[tokio::test] + async fn pending_agent_resume_spacing_survives_events_and_failed_restores() { + for delay_ms in [100, 250, 0] { + let config: crate::config::Config = toml::from_str(&format!( + "[session]\nstartup_per_agent_delay_ms = {delay_ms}" + )) + .unwrap(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = (0..4) + .map(|_| crate::workspace::Workspace::test_new("restore")) + .collect(); + app.state.active = Some(0); + app.state.view.terminal_area = Rect::new(0, 0, 100, 30); + app.state.ensure_test_terminals(); + let missing = std::env::current_dir() + .unwrap() + .join("__missing_resume_cwd__"); + assert!(!missing.exists()); + for terminal in app.state.terminals.values_mut() { + terminal.cwd = missing.clone(); + terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["codex".into()], + dedupe_key: terminal.id.to_string(), + }); + } + let now = Instant::now(); + app.sync_pending_agent_resume_deadline(now); + assert!(!app.start_pending_agent_resumes(now, false)); + assert!(app.start_pending_agent_resumes(now, true)); + if delay_ms != 0 { + let next = now + std::time::Duration::from_millis(delay_ms); + assert_eq!( + app.state + .terminals + .values() + .filter(|t| t.restore_error.is_some()) + .count(), + 1 + ); + // Geometry changes clear the wakeup, but must preserve the launch gap. + app.pending_agent_resume_deadline = None; + app.sync_pending_agent_resume_deadline(now); + assert_eq!(app.pending_agent_resume_deadline, Some(next)); + assert!(!app + .start_pending_agent_resumes(next - std::time::Duration::from_millis(1), true)); + // A late wakeup must not release every overdue agent in a burst. + for processed in 2..=4 { + let late = now + std::time::Duration::from_secs(processed * 10); + assert!(app.start_pending_agent_resumes(late, true)); + assert_eq!( + app.state + .terminals + .values() + .filter(|t| t.restore_error.is_some()) + .count(), + processed as usize + ); + } + } + assert!(!app.has_pending_agent_resumes()); + assert!(app.pending_agent_resume_deadline.is_none()); + assert!(app.next_agent_resume_at.is_none()); + assert_eq!( + app.state + .terminals + .values() + .filter(|t| t.restore_error.is_some()) + .count(), + 4 + ); + } + } + #[cfg(unix)] fn long_running_test_argv() -> Vec { vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()] @@ -378,6 +491,49 @@ mod tests { ] } + #[cfg(unix)] + #[tokio::test] + async fn failed_deferred_restore_keeps_session_reference_without_retrying_elsewhere() { + for missing_shell in [false, true] { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("unavailable"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + if missing_shell { + app.state.default_shell = "__herdr_missing_resume_shell__".into(); + } + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + if !missing_shell { + terminal.cwd = std::env::current_dir() + .unwrap() + .join("__herdr_missing_resume_cwd__"); + assert!(!terminal.cwd.exists()); + } + let session = crate::agent_resume::PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("resume-test").unwrap(), + }; + terminal.persisted_agent_session = Some(session.clone()); + terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: long_running_test_argv(), + dedupe_key: "resume-test".into(), + }); + app.start_pending_agent_resume_for_terminal(&terminal_id, 24, 80, true); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + let terminal = &app.state.terminals[&terminal_id]; + assert!(terminal.pending_agent_resume_plan.is_none()); + assert_eq!(terminal.persisted_agent_session.as_ref(), Some(&session)); + assert!(terminal.restore_error.is_some()); + assert!(!app.has_pending_agent_resumes()); + assert!(!app.start_pending_agent_resume_for_terminal(&terminal_id, 24, 80, true)); + } + } + #[cfg(unix)] #[tokio::test] async fn pending_agent_resume_waits_for_host_theme_before_launch() { @@ -404,7 +560,7 @@ mod tests { dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); - assert!(!app.start_pending_agent_resumes(false)); + assert!(!app.start_pending_agent_resumes(Instant::now(), false)); assert!(app.terminal_runtimes.get(&terminal_id).is_none()); app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { @@ -421,7 +577,7 @@ mod tests { ..Default::default() }; - assert!(app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(Instant::now(), false)); assert!(app.terminal_runtimes.get(&terminal_id).is_some()); let terminal = app .state @@ -483,8 +639,8 @@ mod tests { }); app.sync_pending_agent_resume_deadline(std::time::Instant::now()); - assert!(!app.start_pending_agent_resumes(false)); - assert!(app.start_pending_agent_resumes(true)); + assert!(!app.start_pending_agent_resumes(Instant::now(), false)); + assert!(app.start_pending_agent_resumes(Instant::now(), true)); assert!(app.terminal_runtimes.get(&terminal_id).is_some()); for (_, runtime) in app.terminal_runtimes.drain() { @@ -536,8 +692,14 @@ mod tests { app.pending_agent_resume_deadline = Some(std::time::Instant::now() - std::time::Duration::from_millis(1)); - assert!(app.start_pending_agent_resumes(false)); + let now = Instant::now(); + assert!(app.start_pending_agent_resumes(now, false)); assert!(app.terminal_runtimes.get(&active_terminal).is_some()); + assert!(app.terminal_runtimes.get(&hidden_terminal).is_none()); + assert!(!app.start_pending_agent_resumes(now, true)); + assert!( + app.start_pending_agent_resumes(now + std::time::Duration::from_millis(100), false,) + ); assert!(app.terminal_runtimes.get(&hidden_terminal).is_some()); assert!( app.pending_agent_resume_deadline.is_none(), @@ -597,7 +759,7 @@ mod tests { dedupe_key: "herdr:codex\0codex\0Id\0inactive-tab-session".into(), }); - assert!(app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(Instant::now(), false)); assert!(app.terminal_runtimes.get(&inactive_terminal).is_some()); assert!( app.state @@ -658,7 +820,7 @@ mod tests { dedupe_key: "herdr:codex\0codex\0Id\0zoom-hidden-session".into(), }); - assert!(app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(Instant::now(), false)); assert!(app.terminal_runtimes.get(&hidden_terminal).is_some()); assert!( app.state @@ -718,7 +880,7 @@ mod tests { app.sync_pending_agent_resume_deadline(std::time::Instant::now()); assert!(app.pending_agent_resume_deadline.is_some()); - assert!(app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(Instant::now(), false)); assert!(app.terminal_runtimes.get(&previous_terminal).is_some()); assert!( app.state @@ -777,7 +939,7 @@ mod tests { dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); - assert!(app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(Instant::now(), false)); assert_eq!( app.terminal_runtimes .get(&terminal_id) diff --git a/src/app/agents.rs b/src/app/agents.rs index 5033505d..fd2a58ed 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -395,6 +395,7 @@ impl App { launch_pending: terminal.managed_agent_launch_pending(), interactive_ready: terminal.managed_agent_interactive_ready(), state_change_seq: terminal.last_agent_state_change_seq.unwrap_or(0), + completion_seq: terminal.last_agent_completion_seq, cwd: pane.cwd, foreground_cwd: pane.foreground_cwd, revision: pane.revision, diff --git a/src/app/api.rs b/src/app/api.rs index 1270feff..20ca9d0f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -5,7 +5,6 @@ mod agents; mod env; mod integrations; mod layouts; -mod pane_graphics; mod panes; pub(crate) mod plugins; pub(super) mod responses; @@ -39,6 +38,14 @@ impl App { segment_index, result, } => self.handle_tab_bar_command_finished(generation, segment_index, result), + AppEvent::WorktreeReadFinished(result) => { + let changes_workspace = matches!( + &result.request.method, + crate::api::schema::Method::WorktreeOpen(_) + ); + self.handle_api_worktree_read_finished(*result); + changes_workspace + } ev @ AppEvent::TerminalBell { .. } => { self.handle_internal_event(ev); false @@ -157,6 +164,11 @@ impl App { return Vec::new(); } + if let AppEvent::WorktreeReadFinished(result) = ev { + self.handle_api_worktree_read_finished(*result); + return Vec::new(); + } + if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_api_worktree_add_finished(*result); return Vec::new(); @@ -450,6 +462,7 @@ impl App { runtime.set_full_lifecycle_authority_active( terminal.full_lifecycle_hook_authority_active(), ); + runtime.set_self_reported_agent_active(terminal.self_reported_agent_active()); } } } @@ -899,6 +912,13 @@ impl App { result: ResponseResult::Ok {}, } } + Method::ServerSshAgentRegister(_) => { + return responses::encode_error( + request.id, + "connection_local_only", + "SSH agent registration requires a persistent local JSON API connection", + ); + } Method::ServerLiveHandoff(_) => { let response = ErrorResponse { id: request.id, @@ -1028,7 +1048,13 @@ impl App { Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target); } - Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), + Method::WorktreeList(_) | Method::WorktreeOpen(_) => { + return responses::encode_error( + request.id, + "invalid_request", + "worktree discovery is handled asynchronously by the app runtime", + ); + } Method::WorktreeCreate(params) => { let _ = params; return responses::encode_error( @@ -1037,7 +1063,6 @@ impl App { "worktree.create is handled asynchronously by the app runtime", ); } - Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params), Method::WorktreeRemove(params) => { let _ = params; return responses::encode_error( @@ -1101,6 +1126,7 @@ impl App { } Method::PaneResize(params) => return self.handle_pane_resize(request.id, params), Method::PaneScroll(params) => return self.handle_pane_scroll(request.id, params), + Method::PaneClear(target) => return self.handle_pane_clear(request.id, target), Method::PaneEditScrollback(target) => { return self.handle_pane_edit_scrollback(request.id, target); } @@ -1126,34 +1152,6 @@ impl App { } Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), - Method::PaneGraphicsSet(params) => { - return self.handle_pane_graphics_set(request.id, params); - } - Method::PaneGraphicsClear(params) => { - return self.handle_pane_graphics_clear(request.id, params); - } - Method::PaneGraphicsInfo(params) => { - return self.handle_pane_graphics_info(request.id, params); - } - Method::PaneGraphicsStream(_) => { - return responses::encode_error( - request.id, - "stream_transport_required", - "pane.graphics.stream requires the streaming socket transport", - ); - } - Method::PaneGraphicsStreamSet(params) => { - return self.handle_pane_graphics_stream_set(request.id, params); - } - Method::PaneGraphicsStreamDirect(params) => { - return self.handle_pane_graphics_stream_direct(request.id, params); - } - Method::PaneGraphicsStreamOpen(params) => { - return self.handle_pane_graphics_stream_open(request.id, params); - } - Method::PaneGraphicsStreamClose(params) => { - return self.handle_pane_graphics_stream_close(request.id, params); - } Method::PaneReportAgent(params) => { return self.handle_pane_report_agent(request.id, params); } diff --git a/src/app/api/pane_graphics.rs b/src/app/api/pane_graphics.rs deleted file mode 100644 index 649282b5..00000000 --- a/src/app/api/pane_graphics.rs +++ /dev/null @@ -1,1284 +0,0 @@ -use base64::Engine; - -use crate::api::schema::{ - PaneGraphicsClearParams, PaneGraphicsSetParams, PaneGraphicsStreamParams, ResponseResult, - PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES, PANE_GRAPHICS_MAX_LAYERS_PER_PANE, - PANE_GRAPHICS_PRIMARY_LAYER_ID, PANE_GRAPHICS_SET_MAX_BYTES, PANE_GRAPHICS_STREAM_MAX_BYTES, -}; -use crate::app::pane_graphics::{Key as PaneGraphicsKey, Layer, Slot}; -use crate::app::App; -use crate::layout::PaneId; - -use super::responses::{encode_error, encode_success}; - -impl App { - /// Whether graphics for a pane can be placed on the surface rendered by this client. - /// - /// This deliberately follows the pane layout rather than pane existence: inactive - /// workspaces/tabs and panes hidden by zoom are not placeable. Short-lived UI modes do not - /// suspend the producer because the pane becomes visible again without a layout event. - fn pane_graphics_visible(&self, ws_idx: usize, pane_id: PaneId) -> bool { - self.state.pane_visible_on_active_surface(ws_idx, pane_id) - } - - pub(super) fn handle_pane_graphics_info( - &mut self, - id: String, - target: crate::api::schema::PaneTarget, - ) -> String { - if let Err(response) = require_enabled(self, &id) { - return response; - } - let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { - return pane_not_found(id, &target.pane_id); - }; - let pane_visible = self.pane_graphics_visible(ws_idx, pane_id); - if !self.state.host_cell_size.is_known() { - return encode_error(id, "cell_size_unavailable", "host cell size is unavailable"); - } - let file_frame_directory = self - .direct_graphics_available - .then(|| self.pane_graphics_files.source_directory().ok()) - .flatten(); - let direct = file_frame_directory.is_some(); - encode_success( - id, - ResponseResult::PaneGraphicsInfo { - cell_width_px: self.state.host_cell_size.width_px, - cell_height_px: self.state.host_cell_size.height_px, - pane_visible, - file_frame_directory: file_frame_directory - .map(|directory| directory.to_string_lossy().into_owned()), - file_frame_formats: if direct { - vec!["rgba".into(), "bgra".into()] - } else { - Vec::new() - }, - file_frame_max_bytes: direct.then_some(PANE_GRAPHICS_STREAM_MAX_BYTES), - file_frame_direct_max_bytes: direct.then_some(PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES), - // Damage metadata never changes the complete canonical frame contract. - file_frame_damage: true, - max_layers_per_pane: PANE_GRAPHICS_MAX_LAYERS_PER_PANE, - pixel_mouse: self.pixel_mouse_available, - file_frame_transport: direct.then(|| "direct-kitty".into()), - }, - ) - } - - pub(crate) fn attach_pane_graphics_stream_active( - &mut self, - params: &PaneGraphicsStreamParams, - active: std::sync::Arc, - response: &str, - ) { - if serde_json::from_str::(response).is_err() { - return; - } - let Some((_, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return; - }; - let Ok(key) = graphics_key(pane_id, params.layer_id.as_deref()) else { - return; - }; - self.pane_graphics - .attach_stream_active(&key, ¶ms.owner, active); - } - - pub(super) fn handle_pane_graphics_set( - &mut self, - id: String, - params: PaneGraphicsSetParams, - ) -> String { - if let Err(response) = require_enabled(self, &id) { - return response; - } - let Some((_, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return pane_not_found(id, ¶ms.pane_id); - }; - let key = match graphics_key(pane_id, params.layer_id.as_deref()) { - Ok(key) => key, - Err(message) => return encode_error(id, "invalid_layer_id", message), - }; - self.reclaim_inactive_stream(&key); - if self - .pane_graphics - .slots - .get(&key) - .is_some_and(|slot| slot.stream_owner.is_some()) - { - return encode_error( - id, - "stream_conflict", - "pane graphics layer has an active stream", - ); - } - self.set_layer(id, key, params) - } - - pub(super) fn handle_pane_graphics_clear( - &mut self, - id: String, - params: PaneGraphicsClearParams, - ) -> String { - if let Err(response) = require_enabled(self, &id) { - return response; - } - let Some((_, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return pane_not_found(id, ¶ms.pane_id); - }; - let key = match graphics_key(pane_id, params.layer_id.as_deref()) { - Ok(key) => key, - Err(message) => return encode_error(id, "invalid_layer_id", message), - }; - self.reclaim_inactive_stream(&key); - if self - .pane_graphics - .slots - .get(&key) - .is_some_and(|slot| slot.stream_owner.is_some()) - { - return encode_error( - id, - "stream_conflict", - "pane graphics layer has an active stream", - ); - } - if self.pane_graphics.slots.remove(&key).is_some() { - self.pane_graphics.mark_changed(); - } - encode_success(id, ResponseResult::Ok {}) - } - - pub(super) fn handle_pane_graphics_stream_set( - &mut self, - id: String, - params: PaneGraphicsSetParams, - ) -> String { - if let Err(response) = require_enabled(self, &id) { - return response; - } - let Some((_, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return pane_not_found(id, ¶ms.pane_id); - }; - let key = match graphics_key(pane_id, params.layer_id.as_deref()) { - Ok(key) => key, - Err(message) => return encode_error(id, "invalid_layer_id", message), - }; - match self.pane_graphics.slots.get(&key) { - Some(slot) - if slot.stream_owner.as_ref() == Some(¶ms.owner) && slot.stream_is_active() => - { - self.set_layer(id, key, params) - } - Some(slot) if slot.stream_owner.as_ref() == Some(¶ms.owner) => { - encode_error(id, "stream_closed", "pane graphics stream is not active") - } - Some(_) => encode_error( - id, - "stream_conflict", - "pane graphics stream owner does not match active stream", - ), - None => encode_error(id, "stream_closed", "pane graphics stream is not active"), - } - } - - pub(super) fn handle_pane_graphics_stream_open( - &mut self, - id: String, - params: PaneGraphicsStreamParams, - ) -> String { - if let Err(response) = require_enabled(self, &id) { - return response; - } - if params.owner.is_empty() { - return encode_error( - id, - "invalid_stream", - "pane graphics stream owner is required", - ); - } - let Some((_, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return pane_not_found(id, ¶ms.pane_id); - }; - let key = match graphics_key(pane_id, params.layer_id.as_deref()) { - Ok(key) => key, - Err(message) => return encode_error(id, "invalid_layer_id", message), - }; - let stale = self - .pane_graphics - .slots - .get(&key) - .is_some_and(|slot| slot.stream_owner.is_some() && !slot.stream_is_active()); - if stale { - self.pane_graphics.slots.remove(&key); - } - if self - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.stream_owner.as_ref()) - .is_some() - { - return encode_error( - id, - "stream_conflict", - "pane graphics layer has an active stream", - ); - } - let exists = self.pane_graphics.slots.contains_key(&key); - if !self.pane_graphics.can_add_slot(&key) - || (!exists - && self.pane_graphics.layer_count(pane_id) >= PANE_GRAPHICS_MAX_LAYERS_PER_PANE) - { - return encode_error(id, "layer_limit", "pane graphics layer limit reached"); - } - let Some(host_image_id) = self.pane_graphics.reserve_image_id(&key) else { - return encode_error(id, "layer_limit", "pane graphics image ids are exhausted"); - }; - self.pane_graphics.slots.insert( - key, - Slot { - host_image_id, - layer: None, - stream_owner: Some(params.owner), - stream_active: Some(std::sync::Arc::new(std::sync::atomic::AtomicBool::new( - true, - ))), - direct_gate: None, - }, - ); - self.pane_graphics.mark_changed(); - encode_success(id, ResponseResult::Ok {}) - } - - pub(super) fn handle_pane_graphics_stream_close( - &mut self, - id: String, - params: PaneGraphicsStreamParams, - ) -> String { - let Some((_, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return pane_not_found(id, ¶ms.pane_id); - }; - if let Ok(key) = graphics_key(pane_id, params.layer_id.as_deref()) { - if self - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.stream_owner.as_ref()) - .is_some_and(|owner| owner == ¶ms.owner) - { - self.pane_graphics.slots.remove(&key); - self.pane_graphics.mark_changed(); - } - } - encode_success(id, ResponseResult::Ok {}) - } - - pub(super) fn handle_pane_graphics_stream_direct( - &mut self, - id: String, - params: crate::api::schema::PaneGraphicsDirectParams, - ) -> String { - if let Err(response) = require_enabled(self, &id) { - return response; - } - let Some((_, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return pane_not_found(id, ¶ms.pane_id); - }; - let key = match graphics_key(pane_id, params.layer_id.as_deref()) { - Ok(key) => key, - Err(message) => return encode_error(id, "invalid_layer_id", message), - }; - let owner_matches = self.pane_graphics.slots.get(&key).is_some_and(|slot| { - slot.stream_owner.as_deref() == Some(params.owner.as_str()) && slot.stream_is_active() - }); - if !owner_matches { - return encode_error(id, "stream_closed", "pane graphics stream is not active"); - } - if params.image_width == 0 || params.image_height == 0 { - return encode_error( - id, - "invalid_image", - "image_width and image_height must be greater than zero", - ); - } - if !matches!( - params.format, - crate::api::schema::PaneGraphicsFormat::Rgba - | crate::api::schema::PaneGraphicsFormat::Bgra - ) { - return encode_error(id, "invalid_image", "direct frames require rgba or bgra"); - } - let primary = key.1 == PANE_GRAPHICS_PRIMARY_LAYER_ID; - let direct = self.direct_graphics_available - && primary - && params.format == crate::api::schema::PaneGraphicsFormat::Rgba; - let max_bytes = if direct { - PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES - } else { - PANE_GRAPHICS_STREAM_MAX_BYTES - }; - let expected_len = - match expected_len(params.format, params.image_width, params.image_height) { - Ok(Some(len)) if len <= max_bytes => len, - _ => return encode_error(id, "invalid_image", "invalid direct RGBA dimensions"), - }; - let lease = match self - .pane_graphics_files - .lease(std::path::Path::new(¶ms.path), expected_len) - { - Ok(lease) => lease, - Err(err) => return encode_error(id, "invalid_frame_file", err.to_string()), - }; - if !direct && !self.pane_graphics.can_store_inline(&key, expected_len) { - return encode_error( - id, - "graphics_budget_exceeded", - "pane graphics inline memory limit reached", - ); - } - let layer = if direct { - Layer::direct( - params.image_width, - params.image_height, - lease, - params.placement, - params.z_index, - ) - } else { - let mut data = match lease.copy_rgba() { - Ok(data) => data, - Err(err) => return encode_error(id, "invalid_frame_file", err.to_string()), - }; - canonicalize_bgra(params.format, &mut data); - Layer::inline( - crate::api::schema::PaneGraphicsFormat::Rgba, - params.image_width, - params.image_height, - data, - params.placement, - params.z_index, - ) - }; - let Some(slot) = self.pane_graphics.slots.get_mut(&key) else { - return encode_error(id, "stream_closed", "pane graphics stream is not active"); - }; - if !slot.stream_is_active() { - return encode_error(id, "stream_closed", "pane graphics stream is not active"); - } - slot.layer = Some(layer); - slot.direct_gate = None; - self.pane_graphics.mark_changed(); - encode_success( - id, - ResponseResult::PaneGraphicsFrameAck { - sequence: params.sequence, - revision: params.revision, - }, - ) - } - - fn set_layer( - &mut self, - id: String, - key: PaneGraphicsKey, - params: PaneGraphicsSetParams, - ) -> String { - if !self.pane_graphics.can_add_slot(&key) - || (!self.pane_graphics.slots.contains_key(&key) - && self.pane_graphics.layer_count(key.0) >= PANE_GRAPHICS_MAX_LAYERS_PER_PANE) - { - return encode_error(id, "layer_limit", "pane graphics layer limit reached"); - } - if params.image_width == 0 || params.image_height == 0 { - return encode_error( - id, - "invalid_image", - "image_width and image_height must be greater than zero", - ); - } - let expected = match expected_len(params.format, params.image_width, params.image_height) { - Ok(expected) => expected, - Err(()) => return encode_error(id, "invalid_image", "image dimensions are too large"), - }; - let streamed = params.data.is_some(); - let mut data = match params.data { - Some(data) => data, - None => match base64::engine::general_purpose::STANDARD.decode(params.data_base64) { - Ok(data) => data, - Err(_) => { - return encode_error(id, "invalid_image", "data_base64 is not valid base64") - } - }, - }; - let limit = if streamed { - PANE_GRAPHICS_STREAM_MAX_BYTES - } else { - PANE_GRAPHICS_SET_MAX_BYTES - }; - if data.is_empty() { - return encode_error(id, "invalid_image", "image data must not be empty"); - } - if data.len() > limit { - return encode_error(id, "image_too_large", "image data is too large"); - } - if expected.is_some_and(|size| size != data.len()) { - return encode_error( - id, - "invalid_image", - "image data does not match the frame contract", - ); - } - let format = canonicalize_bgra(params.format, &mut data); - if !self.pane_graphics.can_store_inline(&key, data.len()) { - return encode_error( - id, - "graphics_budget_exceeded", - "pane graphics inline memory limit reached", - ); - } - let Some(host_image_id) = self.pane_graphics.reserve_image_id(&key) else { - return encode_error(id, "layer_limit", "pane graphics image ids are exhausted"); - }; - // Move the liveness handle out before replacing the slot so Slot::drop cannot - // deactivate the stream that the replacement still owns. - let (stream_owner, stream_active) = self - .pane_graphics - .slots - .get_mut(&key) - .map(|slot| (slot.stream_owner.take(), slot.stream_active.take())) - .unwrap_or_default(); - self.pane_graphics.slots.insert( - key, - Slot { - host_image_id, - layer: Some(Layer::inline( - format, - params.image_width, - params.image_height, - data, - params.placement, - params.z_index, - )), - stream_owner, - stream_active, - direct_gate: None, - }, - ); - self.pane_graphics.mark_changed(); - encode_success(id, ResponseResult::Ok {}) - } - - fn reclaim_inactive_stream(&mut self, key: &PaneGraphicsKey) { - let stale = self - .pane_graphics - .slots - .get(key) - .is_some_and(|slot| slot.stream_owner.is_some() && !slot.stream_is_active()); - if stale { - self.pane_graphics.slots.remove(key); - self.pane_graphics.mark_changed(); - } - } -} - -fn canonicalize_bgra( - format: crate::api::schema::PaneGraphicsFormat, - data: &mut [u8], -) -> crate::api::schema::PaneGraphicsFormat { - if format != crate::api::schema::PaneGraphicsFormat::Bgra { - return format; - } - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - crate::api::schema::PaneGraphicsFormat::Rgba -} - -fn graphics_key(pane_id: PaneId, layer_id: Option<&str>) -> Result { - let layer_id = layer_id.unwrap_or(PANE_GRAPHICS_PRIMARY_LAYER_ID); - if layer_id.is_empty() || layer_id.len() > 64 { - return Err("layer_id must contain between 1 and 64 characters"); - } - if !layer_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) - { - return Err("layer_id contains unsupported characters"); - } - Ok((pane_id, layer_id.to_owned())) -} - -fn expected_len( - format: crate::api::schema::PaneGraphicsFormat, - width: u32, - height: u32, -) -> Result, ()> { - let bytes = match format { - crate::api::schema::PaneGraphicsFormat::Png => return Ok(None), - crate::api::schema::PaneGraphicsFormat::Rgb => 3_u64, - crate::api::schema::PaneGraphicsFormat::Rgba - | crate::api::schema::PaneGraphicsFormat::Bgra => 4_u64, - }; - u64::from(width) - .checked_mul(u64::from(height)) - .and_then(|pixels| pixels.checked_mul(bytes)) - .and_then(|size| usize::try_from(size).ok()) - .map(Some) - .ok_or(()) -} - -fn require_enabled(app: &App, id: &str) -> Result<(), String> { - app.state - .kitty_graphics_enabled - .then_some(()) - .ok_or_else(|| { - encode_error( - id.to_owned(), - "feature_disabled", - "pane graphics are disabled by terminal.kitty_graphics", - ) - }) -} - -fn pane_not_found(id: String, pane_id: &str) -> String { - encode_error(id, "pane_not_found", format!("pane {pane_id} not found")) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::api::schema::{ErrorResponse, SuccessResponse}; - use crate::config::Config; - use crate::workspace::Workspace; - - fn app() -> (App, String) { - let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new( - &Config::default(), - crate::app::AppPolicy::TEST, - None, - rx, - crate::api::EventHub::default(), - ); - app.state.workspaces = vec![Workspace::test_new("graphics")]; - app.state.ensure_test_terminals(); - let pane = app.state.workspaces[0].tabs[0].root_pane; - let public = app.public_pane_id(0, pane).unwrap(); - app.state.kitty_graphics_enabled = true; - (app, public) - } - - fn frame( - pane_id: String, - layer_id: Option<&str>, - owner: &str, - value: u8, - ) -> PaneGraphicsSetParams { - PaneGraphicsSetParams { - pane_id, - layer_id: layer_id.map(str::to_owned), - z_index: 2, - owner: owner.to_owned(), - format: crate::api::schema::PaneGraphicsFormat::Rgba, - image_width: 1, - image_height: 1, - data: Some(vec![value; 4]), - data_base64: String::new(), - placement: Default::default(), - } - } - - fn pane_visible(response: &str) -> bool { - serde_json::from_str::(response).unwrap()["result"]["pane_visible"] - .as_bool() - .unwrap() - } - - #[test] - fn info_reports_visibility_for_terminal_surface_workspace_tab_and_zoom() { - let (mut app, pane_id) = app(); - app.state.mode = crate::app::Mode::Terminal; - app.state.active = Some(0); - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - let visible = app.handle_pane_graphics_info( - "visible".into(), - crate::api::schema::PaneTarget { - pane_id: pane_id.clone(), - }, - ); - assert!(pane_visible(&visible)); - - let hidden_workspace = Workspace::test_new("hidden"); - let hidden_workspace_pane = hidden_workspace.tabs[0].root_pane; - app.state.workspaces.push(hidden_workspace); - let hidden_workspace_id = app.public_pane_id(1, hidden_workspace_pane).unwrap(); - let hidden = app.handle_pane_graphics_info( - "hidden-workspace".into(), - crate::api::schema::PaneTarget { - pane_id: hidden_workspace_id, - }, - ); - assert!(!pane_visible(&hidden)); - - let inactive_tab = app.state.workspaces[0].test_add_tab(Some("inactive")); - let inactive_pane = app.state.workspaces[0].tabs[inactive_tab].root_pane; - let inactive_id = app.public_pane_id(0, inactive_pane).unwrap(); - let hidden_tab = app.handle_pane_graphics_info( - "hidden-tab".into(), - crate::api::schema::PaneTarget { - pane_id: inactive_id, - }, - ); - assert!(!pane_visible(&hidden_tab)); - - app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - app.state.workspaces[0].tabs[0].zoomed = true; - let zoomed_away = app.handle_pane_graphics_info( - "zoomed-away".into(), - crate::api::schema::PaneTarget { - pane_id: pane_id.clone(), - }, - ); - assert!(!pane_visible(&zoomed_away)); - - app.state.workspaces[0].tabs[0].zoomed = false; - app.state.mode = crate::app::Mode::Navigate; - let short_lived_mode = app.handle_pane_graphics_info( - "navigate".into(), - crate::api::schema::PaneTarget { pane_id }, - ); - assert!(pane_visible(&short_lived_mode)); - } - - #[test] - fn non_persistent_info_keeps_fast_transport_and_exact_pixels_disabled() { - let (mut app, pane_id) = app(); - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - assert!(!app.pane_graphics_files.is_initialized()); - let response = app - .handle_pane_graphics_info("info".into(), crate::api::schema::PaneTarget { pane_id }); - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!( - success.result, - ResponseResult::PaneGraphicsInfo { - file_frame_damage: true, - max_layers_per_pane: 16, - pixel_mouse: false, - .. - } - )); - assert!(app.pane_graphics.slots.is_empty()); - assert!(!app.pane_graphics_files.is_initialized()); - } - - #[cfg(unix)] - #[test] - fn info_advertises_rgba_direct_and_bgra_fallback_file_formats_on_demand() { - let (mut app, pane_id) = app(); - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - app.direct_graphics_available = true; - let response = app - .handle_pane_graphics_info("info".into(), crate::api::schema::PaneTarget { pane_id }); - let value: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert!(app.pane_graphics_files.is_initialized()); - assert_eq!( - value["result"]["file_frame_formats"], - serde_json::json!(["rgba", "bgra"]) - ); - assert_eq!( - value["result"]["file_frame_max_bytes"], - PANE_GRAPHICS_STREAM_MAX_BYTES - ); - assert_eq!( - value["result"]["file_frame_direct_max_bytes"], - PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES - ); - assert_eq!(value["result"]["file_frame_damage"], true); - assert_eq!(value["result"]["file_frame_transport"], "direct-kitty"); - } - - #[test] - fn layers_are_isolated_and_stream_owned() { - let (mut app, pane_id) = app(); - for (layer, z, value) in [(None, 0, 1), (Some("chrome"), 10, 2)] { - let mut request = frame(pane_id.clone(), layer, "", value); - request.z_index = z; - let response = app.handle_pane_graphics_set(format!("set-{value}"), request); - assert!(serde_json::from_str::(&response).is_ok()); - } - let (_, pane) = app.parse_pane_id(&pane_id).unwrap(); - assert_eq!( - app.pane_graphics - .slots - .get(&(pane, PANE_GRAPHICS_PRIMARY_LAYER_ID.into())) - .and_then(|slot| slot.layer.as_ref()) - .unwrap() - .z_index, - 0 - ); - assert_eq!( - app.pane_graphics - .slots - .get(&(pane, "chrome".into())) - .and_then(|slot| slot.layer.as_ref()) - .unwrap() - .z_index, - 10 - ); - let response = app.handle_pane_graphics_stream_open( - "open".into(), - PaneGraphicsStreamParams { - pane_id: pane_id.clone(), - layer_id: Some("chrome".into()), - z_index: 10, - owner: "stream".into(), - }, - ); - assert!(serde_json::from_str::(&response).is_ok()); - let response = - app.handle_pane_graphics_set("conflict".into(), frame(pane_id, Some("chrome"), "", 3)); - let error: ErrorResponse = serde_json::from_str(&response).unwrap(); - assert_eq!(error.error.code, "stream_conflict"); - } - - fn error_code(response: &str) -> String { - serde_json::from_str::(response) - .unwrap() - .error - .code - } - - #[test] - fn validates_layer_and_frame_contracts() { - let cases = [ - (Some("bad space"), 1, 1, vec![1; 4], "invalid_layer_id"), - (None, 0, 1, vec![1; 4], "invalid_image"), - (None, 1, 1, Vec::new(), "invalid_image"), - (None, 1, 1, vec![1; 3], "invalid_image"), - ]; - for (layer, width, height, data, expected) in cases { - let (mut app, pane_id) = app(); - let mut params = frame(pane_id, layer, "", 1); - params.image_width = width; - params.image_height = height; - params.data = Some(data); - let response = app.handle_pane_graphics_set("invalid".into(), params); - assert_eq!(error_code(&response), expected); - assert!(app.pane_graphics.slots.is_empty()); - } - } - - #[test] - fn disabled_requests_have_no_graphics_side_effects() { - let (mut app, pane_id) = app(); - app.state.kitty_graphics_enabled = false; - let revision = app.pane_graphics.revision(); - - let response = app.handle_pane_graphics_set("disabled".into(), frame(pane_id, None, "", 1)); - - assert_eq!(error_code(&response), "feature_disabled"); - assert!(app.pane_graphics.slots.is_empty()); - assert_eq!(app.pane_graphics.revision(), revision); - } - - #[test] - fn stream_owner_controls_only_its_named_layer() { - let (mut app, pane_id) = app(); - let params = |owner: &str| PaneGraphicsStreamParams { - pane_id: pane_id.clone(), - layer_id: Some("chrome".into()), - z_index: 7, - owner: owner.into(), - }; - assert!(serde_json::from_str::( - &app.handle_pane_graphics_stream_open("open".into(), params("owner")) - ) - .is_ok()); - assert_eq!( - error_code(&app.handle_pane_graphics_stream_set( - "wrong".into(), - frame(pane_id.clone(), Some("chrome"), "other", 2), - )), - "stream_conflict" - ); - assert!( - serde_json::from_str::(&app.handle_pane_graphics_stream_set( - "frame".into(), - frame(pane_id.clone(), Some("chrome"), "owner", 3), - )) - .is_ok() - ); - app.handle_pane_graphics_stream_close("stale".into(), params("other")); - assert_eq!(app.pane_graphics.slots.len(), 1); - app.handle_pane_graphics_stream_close("close".into(), params("owner")); - assert!(app.pane_graphics.slots.is_empty()); - } - - #[test] - fn static_set_and_clear_reclaim_inactive_stream_layers() { - let (mut app, pane_id) = app(); - let open = |layer: &str| PaneGraphicsStreamParams { - pane_id: pane_id.clone(), - layer_id: Some(layer.into()), - z_index: 0, - owner: format!("owner-{layer}"), - }; - for layer in ["set", "clear"] { - assert!(serde_json::from_str::( - &app.handle_pane_graphics_stream_open(layer.into(), open(layer)) - ) - .is_ok()); - let (_, pane) = app.parse_pane_id(&pane_id).unwrap(); - app.pane_graphics.slots[&(pane, layer.into())] - .stream_active - .as_ref() - .unwrap() - .store(false, std::sync::atomic::Ordering::Release); - } - - assert!( - serde_json::from_str::(&app.handle_pane_graphics_set( - "set-static".into(), - frame(pane_id.clone(), Some("set"), "", 4), - )) - .is_ok() - ); - assert!( - serde_json::from_str::(&app.handle_pane_graphics_clear( - "clear-static".into(), - PaneGraphicsClearParams { - pane_id, - layer_id: Some("clear".into()), - }, - )) - .is_ok() - ); - assert_eq!(app.pane_graphics.slots.len(), 1); - assert!(app - .pane_graphics - .slots - .values() - .all(|slot| slot.stream_owner.is_none())); - } - - #[test] - fn layer_limit_counts_empty_and_populated_stream_slots() { - let (mut app, pane_id) = app(); - for index in 0..PANE_GRAPHICS_MAX_LAYERS_PER_PANE { - let layer_id = format!("layer-{index}"); - let response = app.handle_pane_graphics_stream_open( - layer_id.clone(), - PaneGraphicsStreamParams { - pane_id: pane_id.clone(), - layer_id: Some(layer_id), - z_index: index as i32, - owner: format!("owner-{index}"), - }, - ); - assert!(serde_json::from_str::(&response).is_ok()); - } - let response = app.handle_pane_graphics_set( - "set-overflow".into(), - frame(pane_id.clone(), Some("set-overflow"), "", 1), - ); - assert_eq!(error_code(&response), "layer_limit"); - let response = app.handle_pane_graphics_stream_open( - "overflow".into(), - PaneGraphicsStreamParams { - pane_id, - layer_id: Some("overflow".into()), - z_index: 0, - owner: "overflow".into(), - }, - ); - assert_eq!(error_code(&response), "layer_limit"); - assert_eq!(app.pane_graphics.slots.len(), 16); - } - - #[test] - fn set_and_clear_preserve_runtime_only_layer_details() { - let (mut app, pane_id) = app(); - let (_, pane) = app.parse_pane_id(&pane_id).unwrap(); - let mut params = frame(pane_id.clone(), None, "", 9); - params.placement.grid_cols = 10; - params.placement.grid_rows = 4; - assert!(serde_json::from_str::( - &app.handle_pane_graphics_set("set".into(), params) - ) - .is_ok()); - let layer = app - .pane_graphics - .slots - .get(&(pane, PANE_GRAPHICS_PRIMARY_LAYER_ID.into())) - .and_then(|slot| slot.layer.as_ref()) - .unwrap(); - assert_eq!(layer.inline_data(), Some([9; 4].as_slice())); - assert_eq!((layer.render.grid_cols, layer.render.grid_rows), (10, 4)); - assert!(!app.state.session_dirty); - - assert!( - serde_json::from_str::(&app.handle_pane_graphics_clear( - "clear".into(), - PaneGraphicsClearParams { - pane_id, - layer_id: None, - }, - )) - .is_ok() - ); - assert!(app.pane_graphics.slots.is_empty()); - } - - #[test] - fn raw_formats_require_exact_non_overflowing_lengths() { - for (format, exact) in [ - (crate::api::schema::PaneGraphicsFormat::Rgb, 6), - (crate::api::schema::PaneGraphicsFormat::Rgba, 8), - ] { - for (length, expected) in [(exact, "ok"), (exact - 1, "invalid_image")] { - let (mut app, pane_id) = app(); - let mut params = frame(pane_id, None, "", 1); - params.format = format; - params.image_width = 2; - params.data = Some(vec![1; length]); - let response = app.handle_pane_graphics_set("length".into(), params); - if expected == "ok" { - assert!(serde_json::from_str::(&response).is_ok()); - } else { - assert_eq!(error_code(&response), expected); - } - } - let (mut app, pane_id) = app(); - let mut params = frame(pane_id, None, "", 1); - params.format = format; - params.image_width = u32::MAX; - params.image_height = u32::MAX; - assert_eq!( - error_code(&app.handle_pane_graphics_set("overflow".into(), params)), - "invalid_image" - ); - } - } - - #[test] - fn public_frames_validate_base64_and_public_size_limit() { - let (mut app, pane_id) = app(); - let mut params = frame(pane_id.clone(), None, "", 1); - params.format = crate::api::schema::PaneGraphicsFormat::Png; - params.data = None; - params.data_base64 = "not base64".into(); - assert_eq!( - error_code(&app.handle_pane_graphics_set("base64".into(), params)), - "invalid_image" - ); - - let mut params = frame(pane_id, None, "", 1); - params.format = crate::api::schema::PaneGraphicsFormat::Png; - params.data = None; - params.data_base64 = - base64::engine::general_purpose::STANDARD - .encode(vec![1; PANE_GRAPHICS_SET_MAX_BYTES + 1]); - assert_eq!( - error_code(&app.handle_pane_graphics_set("large".into(), params)), - "image_too_large" - ); - } - - #[test] - fn aliases_resolve_to_the_same_layer_identity() { - let (mut app, pane_id) = app(); - let (_, pane) = app.parse_pane_id(&pane_id).unwrap(); - let alias = format!("{pane_id}:alias"); - app.state.public_pane_id_aliases.insert(alias.clone(), pane); - let params = |target: String, owner: &str| PaneGraphicsStreamParams { - pane_id: target, - layer_id: None, - z_index: 0, - owner: owner.into(), - }; - assert!(serde_json::from_str::( - &app.handle_pane_graphics_stream_open("open".into(), params(pane_id.clone(), "owner"),) - ) - .is_ok()); - assert_eq!( - error_code(&app.handle_pane_graphics_stream_open( - "conflict".into(), - params(alias.clone(), "other"), - )), - "stream_conflict" - ); - app.handle_pane_graphics_stream_close("stale".into(), params(alias.clone(), "other")); - assert_eq!(app.pane_graphics.slots.len(), 1); - app.handle_pane_graphics_stream_close("close".into(), params(alias, "owner")); - assert!(app.pane_graphics.slots.is_empty()); - } - - #[cfg(unix)] - fn direct_file(app: &App, name: &str, data: &[u8]) -> String { - use std::io::Write as _; - use std::os::unix::fs::OpenOptionsExt as _; - let path = app - .pane_graphics_files - .source_directory() - .unwrap() - .join(name); - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - .unwrap(); - file.write_all(data).unwrap(); - path.to_string_lossy().into_owned() - } - - #[cfg(unix)] - fn sparse_direct_file(app: &App, name: &str, len: usize) -> String { - use std::os::unix::fs::OpenOptionsExt as _; - let path = app - .pane_graphics_files - .source_directory() - .unwrap() - .join(name); - let file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - .unwrap(); - file.set_len(len as u64).unwrap(); - path.to_string_lossy().into_owned() - } - - #[cfg(unix)] - fn direct_params( - pane_id: String, - owner: &str, - path: String, - ) -> crate::api::schema::PaneGraphicsDirectParams { - crate::api::schema::PaneGraphicsDirectParams { - pane_id, - layer_id: None, - z_index: 0, - owner: owner.into(), - image_width: 1, - image_height: 1, - format: crate::api::schema::PaneGraphicsFormat::Rgba, - path, - sequence: 1, - revision: 1, - placement: Default::default(), - } - } - - #[cfg(unix)] - #[test] - fn direct_file_is_leased_or_safely_copied_for_inline_fallback() { - let (mut app, pane_id) = app(); - let stream = PaneGraphicsStreamParams { - pane_id: pane_id.clone(), - layer_id: None, - z_index: 0, - owner: "owner".into(), - }; - app.handle_pane_graphics_stream_open("open".into(), stream.clone()); - for (width, height) in [(0, 1), (1, 0)] { - let mut params = direct_params(pane_id.clone(), "owner", "unused".into()); - (params.image_width, params.image_height) = (width, height); - assert_eq!( - error_code(&app.handle_pane_graphics_stream_direct("invalid".into(), params)), - "invalid_image" - ); - } - for format in [ - crate::api::schema::PaneGraphicsFormat::Rgb, - crate::api::schema::PaneGraphicsFormat::Png, - ] { - let mut params = direct_params(pane_id.clone(), "owner", "unused".into()); - params.format = format; - assert_eq!( - error_code(&app.handle_pane_graphics_stream_direct("invalid".into(), params)), - "invalid_image" - ); - } - app.direct_graphics_available = true; - let path = direct_file(&app, "direct", &[1, 2, 3, 4]); - let response = app.handle_pane_graphics_stream_direct( - "direct".into(), - crate::api::schema::PaneGraphicsDirectParams { - sequence: 7, - revision: 8, - ..direct_params(pane_id.clone(), "owner", path) - }, - ); - let ack: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!( - ack.result, - ResponseResult::PaneGraphicsFrameAck { - sequence: 7, - revision: 8 - } - )); - assert!(app - .pane_graphics - .slots - .values() - .next() - .unwrap() - .layer - .as_ref() - .unwrap() - .direct_lease() - .is_some()); - - app.handle_pane_graphics_stream_close("close".into(), stream.clone()); - app.handle_pane_graphics_stream_open("reopen".into(), stream); - app.direct_graphics_available = false; - let path = direct_file(&app, "fallback", &[5, 6, 7, 8]); - app.handle_pane_graphics_stream_direct( - "fallback".into(), - crate::api::schema::PaneGraphicsDirectParams { - sequence: 9, - revision: 10, - ..direct_params(pane_id, "owner", path) - }, - ); - assert_eq!( - app.pane_graphics - .slots - .values() - .next() - .unwrap() - .layer - .as_ref() - .unwrap() - .inline_data(), - Some([5, 6, 7, 8].as_slice()) - ); - } - - #[cfg(unix)] - #[test] - fn direct_primary_rgba_accepts_fullscreen_retina_frame() { - let (mut app, pane_id) = app(); - app.direct_graphics_available = true; - app.handle_pane_graphics_stream_open( - "open".into(), - PaneGraphicsStreamParams { - pane_id: pane_id.clone(), - layer_id: None, - z_index: 0, - owner: "owner".into(), - }, - ); - let (image_width, image_height) = (3456, 2234); - let len = image_width * image_height * 4; - let path = sparse_direct_file(&app, "retina-frame", len as usize); - let response = app.handle_pane_graphics_stream_direct( - "frame".into(), - crate::api::schema::PaneGraphicsDirectParams { - image_width, - image_height, - ..direct_params(pane_id, "owner", path) - }, - ); - - assert!(serde_json::from_str::(&response).is_ok()); - assert!(app - .pane_graphics - .slots - .values() - .next() - .unwrap() - .layer - .as_ref() - .unwrap() - .direct_lease() - .is_some()); - } - - #[cfg(unix)] - #[test] - fn bgra_and_secondary_file_frames_are_canonical_owned_rgba() { - let (mut app, pane_id) = app(); - app.direct_graphics_available = true; - for (layer, format, source) in [ - ( - "browser-chrome", - crate::api::schema::PaneGraphicsFormat::Rgba, - [1, 2, 3, 4], - ), - ( - "primary", - crate::api::schema::PaneGraphicsFormat::Bgra, - [3, 2, 1, 4], - ), - ] { - app.handle_pane_graphics_stream_open( - format!("open-{layer}"), - PaneGraphicsStreamParams { - pane_id: pane_id.clone(), - layer_id: Some(layer.into()), - z_index: 0, - owner: layer.into(), - }, - ); - let path = direct_file(&app, layer, &source); - app.handle_pane_graphics_stream_direct( - format!("frame-{layer}"), - crate::api::schema::PaneGraphicsDirectParams { - layer_id: Some(layer.into()), - format, - ..direct_params(pane_id.clone(), layer, path) - }, - ); - let key = (app.parse_pane_id(&pane_id).unwrap().1, layer.into()); - assert_eq!( - app.pane_graphics.slots[&key] - .layer - .as_ref() - .unwrap() - .inline_data(), - Some([1, 2, 3, 4].as_slice()) - ); - } - } - - #[test] - fn disabled_info_and_stream_requests_are_rejected() { - let (mut app, pane_id) = app(); - app.state.kitty_graphics_enabled = false; - assert_eq!( - error_code(&app.handle_pane_graphics_info( - "info".into(), - crate::api::schema::PaneTarget { - pane_id: pane_id.clone(), - }, - )), - "feature_disabled" - ); - assert_eq!( - error_code(&app.handle_pane_graphics_stream_open( - "open".into(), - PaneGraphicsStreamParams { - pane_id, - layer_id: None, - z_index: 0, - owner: "owner".into(), - }, - )), - "feature_disabled" - ); - assert!(app.pane_graphics.slots.is_empty()); - } -} diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index f255e9dd..b2e68406 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -167,6 +167,22 @@ impl App { encode_success(id, ResponseResult::PaneInfo { pane }) } + pub(super) fn handle_pane_clear(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, &target.pane_id); + }; + match runtime.clear_screen() { + Ok(()) => encode_success(id, ResponseResult::Ok {}), + Err(err) => encode_error(id, "pane_clear_failed", err.to_string()), + } + } + pub(super) fn handle_pane_scroll(&mut self, id: String, params: PaneScrollParams) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); @@ -1537,28 +1553,44 @@ impl App { id: String, params: PaneReportAgentParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { + return encode_error(id, "invalid_resume_argv", message); + } + let report_is_newer = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); + let session_ref = crate::agent_resume::session_ref_from_report( + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::HookStateReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), - source: params.source, - agent_label, + session_ref: session_ref.clone(), + source: params.source.clone(), + agent_label: agent_label.clone(), state: detect_state_from_api(params.state), message: params.message, seq: params.seq, }); - - encode_success(id, ResponseResult::Ok {}) + let applied = + report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); + self.report_agent_resume( + id, + ws_idx, + pane_id, + params.source, + agent_label, + params.seq.filter(|_| applied), + applied.then_some(params.resume_argv).flatten(), + ) } pub(super) fn handle_pane_report_agent_session( @@ -1566,28 +1598,100 @@ impl App { id: String, params: PaneReportAgentSessionParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { + return encode_error(id, "invalid_resume_argv", message); + } + let report_is_newer = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); + let session_ref = crate::agent_resume::session_ref_from_report( + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::AgentSessionReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), - source: params.source, - agent_label, + session_ref: session_ref.clone(), + source: params.source.clone(), + agent_label: agent_label.clone(), seq: params.seq, session_start_source: crate::agent_resume::normalize_session_start_source( params.session_start_source, ), }); + let applied = + report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); + self.report_agent_resume( + id, + ws_idx, + pane_id, + params.source, + agent_label, + params.seq.filter(|_| applied), + applied.then_some(params.resume_argv).flatten(), + ) + } + + /// A resume command belongs to the session it was reported with, so it is + /// kept only when Herdr accepted that session. + fn session_report_applied( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + session_ref: Option<&crate::agent_resume::AgentSessionRef>, + ) -> bool { + session_ref.is_none_or(|session_ref| { + self.pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.session_ref_is_current(session_ref)) + }) + } + + fn pane_terminal( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalState> { + let pane = self.state.workspaces.get(ws_idx)?.pane_state(pane_id)?; + self.state.terminals.get(&pane.attached_terminal_id) + } + fn report_agent_resume( + &mut self, + id: String, + ws_idx: usize, + pane_id: crate::layout::PaneId, + source: String, + agent_label: String, + seq: Option, + resume_argv: Option>, + ) -> String { + let Some(argv) = resume_argv else { + return encode_success(id, ResponseResult::Ok {}); + }; + let can_record = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.can_record_reported_resume(&source, &agent_label)); + if !can_record { + return encode_error( + id, + "resume_not_accepted", + "resume_argv requires the reporter to hold the pane; report its state with pane.report_agent first", + ); + } + self.handle_internal_event(crate::events::AppEvent::AgentResumeReported { + pane_id, + source, + agent_label, + seq, + argv, + }); encode_success(id, ResponseResult::Ok {}) } @@ -2203,6 +2307,10 @@ fn invalid_agent(id: String) -> String { encode_error(id, "invalid_agent", "agent label must not be empty") } +fn validate_optional_resume_argv(argv: Option<&[String]>) -> Result<(), String> { + argv.map_or(Ok(()), crate::agent_resume::validate_resume_argv) +} + #[cfg(test)] mod tests { use super::*; @@ -2354,6 +2462,26 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn api_clear_pane_mutates_endpoint_owned_history() { + let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); + let request = crate::api::schema::Request { + id: "clear".into(), + method: crate::api::schema::Method::PaneClear(PaneTarget { + pane_id: public_pane_id, + }), + }; + assert!(crate::api::request_changes_ui(&request)); + let response = app.handle_api_request(request); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .unwrap(); + assert_eq!(runtime.scroll_metrics().unwrap().max_offset_from_bottom, 0); + } + #[tokio::test] async fn api_pane_get_exposes_scroll_metrics() { let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs index 1557c168..fb650065 100644 --- a/src/app/api/plugins/manifest.rs +++ b/src/app/api/plugins/manifest.rs @@ -241,19 +241,15 @@ fn validate_min_herdr_version(value: Option<&str>) -> Result compatible { + let current = crate::update::Version::current(); + if required > current { return Err(( "plugin_requires_newer_herdr", - format!( - "plugin requires Herdr {required} or newer; Superherdr supports Herdr plugin requirements up to {compatible}" - ), + format!("plugin requires Herdr {required} or newer; current Herdr is {current}"), )); } Ok(required.to_string()) diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 35dbee2f..c9b37bfe 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -3328,6 +3328,7 @@ action = "missing" seq: None, agent_session_id: None, agent_session_path: None, + resume_argv: None, }, ); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 71f2e03a..b2fc0330 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -1,14 +1,15 @@ use std::path::{Path, PathBuf}; use crate::api::schema::{ - EventData, EventEnvelope, EventKind, ResponseResult, WorktreeInfo, WorktreeListParams, - WorktreeOpenParams, WorktreeSourceInfo, + EventData, EventEnvelope, EventKind, ResponseResult, WorktreeInfo, WorktreeOpenParams, + WorktreeSourceInfo, }; use crate::app::App; use super::responses::{encode_error, encode_success}; mod deferred; +mod reads; struct ApiFailure { code: &'static str, @@ -45,26 +46,12 @@ struct WorktreeSource { } impl App { - pub(super) fn handle_worktree_list( - &mut self, + fn finish_worktree_list( + &self, id: String, - params: WorktreeListParams, + source: WorktreeSource, + entries: Vec, ) -> String { - let source = match self.resolve_worktree_list_source( - params.workspace_id, - params.cwd, - params.trust_repository, - ) { - Ok(source) => source, - Err(err) => return encode_error(id, err.code, err.message), - }; - let entries = match crate::worktree::list_existing_worktrees( - &source.source_repo_root, - params.trust_repository, - ) { - Ok(entries) => entries, - Err(err) => return encode_error(id, "worktree_list_failed", err), - }; let worktrees = entries .into_iter() .map(|entry| self.worktree_info_for_entry(&source, entry)) @@ -79,35 +66,29 @@ impl App { ) } - pub(super) fn handle_worktree_open( + fn finish_worktree_open( &mut self, id: String, params: WorktreeOpenParams, + mut source: WorktreeSource, + entry: crate::worktree::ExistingWorktree, ) -> String { - if params.path.is_some() == params.branch.is_some() { - return encode_error( - id, - "invalid_request", - "exactly one of path or branch is required", - ); - } - let mut source = match self.resolve_worktree_source(params.workspace_id, params.cwd) { - Ok(source) => source, - Err(err) => return encode_error(id, err.code, err.message), - }; - let entry = match self.find_worktree_entry( - &source, - params.path, - params.branch, - params.trust_repository, - ) { - Ok(entry) => entry, - Err(err) => return encode_error(id, err.code, err.message), - }; if entry.is_bare || entry.is_prunable { return encode_error(id, "worktree_not_found", "worktree cannot be opened"); } let canonical_path = crate::worktree::canonical_or_original(&entry.path); + let target_matches = + crate::workspace::git_space_metadata(&entry.path).is_some_and(|space| { + space.key == source.repo_key + && space.checkout_key == canonical_path.display().to_string() + }); + if !target_matches { + return encode_error( + id, + "worktree_not_found", + "worktree checkout changed during discovery; retry the request", + ); + } let canonical_source = crate::worktree::canonical_or_original(&source.source_checkout_path); let target_is_source = canonical_path == canonical_source; let already_open = self.open_workspace_idx_for_checkout(&canonical_path); @@ -243,60 +224,6 @@ impl App { self.worktree_source_from_workspace(ws_idx) } - fn resolve_worktree_list_source( - &mut self, - workspace_id: Option, - cwd: Option, - trust_repository: bool, - ) -> Result { - if workspace_id.is_some() && cwd.is_some() { - return Err(ApiFailure::new( - "invalid_request", - "only one of workspace_id or cwd may be supplied", - )); - } - - if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { - return Err(ApiFailure::new( - "workspace_not_found", - format!("workspace {workspace_id} not found"), - )); - }; - return self.worktree_list_source_from_workspace(ws_idx, trust_repository); - } - - if let Some(cwd) = cwd { - let path = absolute_user_path(&cwd)?; - let space = crate::workspace::git_space_metadata(&path).ok_or_else(|| { - ApiFailure::new( - "not_git_worktree", - "Herdr worktree actions require a path inside a Git work tree", - ) - })?; - let workspace_idx = self.list_source_workspace_idx_for_space(&space, trust_repository); - return Ok(worktree_source_from_space( - space, - workspace_idx, - true, - trust_repository, - )); - } - - let Some(ws_idx) = self.state.active.or_else(|| { - self.state - .workspaces - .get(self.state.selected) - .map(|_| self.state.selected) - }) else { - return Err(ApiFailure::new( - "invalid_request", - "workspace_id or cwd is required when no workspace is active", - )); - }; - self.worktree_list_source_from_workspace(ws_idx, trust_repository) - } - fn worktree_source_from_workspace(&self, ws_idx: usize) -> Result { let Some(ws) = self.state.workspaces.get(ws_idx) else { return Err(ApiFailure::new( @@ -346,61 +273,6 @@ impl App { }) } - fn worktree_list_source_from_workspace( - &self, - ws_idx: usize, - trust_repository: bool, - ) -> Result { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Err(ApiFailure::new( - "workspace_not_found", - "workspace not found", - )); - }; - if let Some(membership) = ws.worktree_space() { - let source_checkout_path = if membership.is_linked_worktree { - membership.repo_root.clone() - } else { - membership.checkout_path.clone() - }; - let workspace_idx = if membership.is_linked_worktree { - self.open_workspace_idx_for_checkout(&membership.repo_root) - } else { - Some(ws_idx) - }; - return Ok(WorktreeSource { - workspace_idx, - source_checkout_path, - source_repo_root: membership.repo_root.clone(), - repo_key: membership.key.clone(), - repo_name: membership.label.clone(), - }); - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - let Some(space) = git_space else { - return Err(ApiFailure::new( - "not_git_worktree", - "Herdr worktree actions require a workspace inside a Git work tree", - )); - }; - let workspace_idx = if space.is_linked_worktree { - self.list_source_workspace_idx_for_space(&space, trust_repository) - } else { - Some(ws_idx) - }; - Ok(worktree_source_from_space( - space, - workspace_idx, - true, - trust_repository, - )) - } - fn ensure_source_parent_membership( &mut self, source: &mut WorktreeSource, @@ -436,19 +308,6 @@ impl App { .or_else(|| self.open_workspace_idx_for_checkout(&space.repo_root)) } - fn list_source_workspace_idx_for_space( - &self, - space: &crate::workspace::GitSpaceMetadata, - trust_repository: bool, - ) -> Option { - if space.is_linked_worktree { - let parent_checkout = parent_checkout_path_for_space(space, trust_repository); - self.open_workspace_idx_for_checkout(&parent_checkout) - } else { - self.find_parent_workspace_for_space(space) - } - } - fn find_parent_workspace_by_key(&self, repo_key: &str) -> Option { self.state.workspaces.iter().position(|ws| { ws.worktree_space() @@ -495,52 +354,6 @@ impl App { } } - fn find_worktree_entry( - &self, - source: &WorktreeSource, - path: Option, - branch: Option, - trust_repository: bool, - ) -> Result { - let entries = - crate::worktree::list_existing_worktrees(&source.source_repo_root, trust_repository) - .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; - if let Some(path) = path { - let expected = absolute_user_path(&path)?; - let expected = crate::worktree::canonical_or_original(&expected); - entries - .into_iter() - .find(|entry| crate::worktree::canonical_or_original(&entry.path) == expected) - .ok_or_else(|| ApiFailure::new("worktree_not_found", "worktree path not found")) - } else if let Some(branch) = branch { - let matches = entries - .into_iter() - .filter(|entry| { - !entry.is_bare - && !entry.is_prunable - && !entry.is_detached - && entry.branch.as_deref() == Some(branch.as_str()) - }) - .collect::>(); - match matches.len() { - 0 => Err(ApiFailure::new( - "worktree_not_found", - "worktree branch not found", - )), - 1 => Ok(matches.into_iter().next().expect("one match should exist")), - _ => Err(ApiFailure::new( - "ambiguous_worktree_branch", - "multiple worktrees matched branch", - )), - } - } else { - Err(ApiFailure::new( - "invalid_request", - "exactly one of path or branch is required", - )) - } - } - fn worktree_source_info(&self, source: &WorktreeSource) -> WorktreeSourceInfo { WorktreeSourceInfo { repo_key: source.repo_key.clone(), @@ -597,10 +410,10 @@ impl App { let canonical_checkout = crate::worktree::canonical_or_original(checkout_path); let checkout_key = canonical_checkout.display().to_string(); self.state.workspaces.iter().position(|ws| { - if ws.worktree_space().is_some_and(|space| { - crate::worktree::canonical_or_original(&space.checkout_path) == canonical_checkout - }) { - return true; + if let Some(space) = ws.worktree_space() { + // Explicit checkout provenance must not be overridden by shell navigation. + return crate::worktree::canonical_or_original(&space.checkout_path) + == canonical_checkout; } let git_space = ws.git_space().cloned().or_else(|| { @@ -682,49 +495,47 @@ impl App { } } -fn worktree_source_from_space( - space: crate::workspace::GitSpaceMetadata, - workspace_idx: Option, - allow_linked: bool, - trust_repository: bool, -) -> WorktreeSource { - let source_checkout_path = if allow_linked { - parent_checkout_path_for_space(&space, trust_repository) +fn find_worktree_entry( + entries: Vec, + path: Option, + branch: Option, +) -> Result { + if let Some(path) = path { + let expected = absolute_user_path(&path)?; + let expected = crate::worktree::canonical_or_original(&expected); + entries + .into_iter() + .find(|entry| crate::worktree::canonical_or_original(&entry.path) == expected) + .ok_or_else(|| ApiFailure::new("worktree_not_found", "worktree path not found")) + } else if let Some(branch) = branch { + let matches = entries + .into_iter() + .filter(|entry| { + !entry.is_bare + && !entry.is_prunable + && !entry.is_detached + && entry.branch.as_deref() == Some(branch.as_str()) + }) + .collect::>(); + match matches.len() { + 0 => Err(ApiFailure::new( + "worktree_not_found", + "worktree branch not found", + )), + 1 => Ok(matches.into_iter().next().expect("one match should exist")), + _ => Err(ApiFailure::new( + "ambiguous_worktree_branch", + "multiple worktrees matched branch", + )), + } } else { - space.repo_root.clone() - }; - WorktreeSource { - workspace_idx, - source_checkout_path: source_checkout_path.clone(), - source_repo_root: source_checkout_path, - repo_key: space.key, - repo_name: space.repo_name, + Err(ApiFailure::new( + "invalid_request", + "exactly one of path or branch is required", + )) } } -fn parent_checkout_path_for_space( - space: &crate::workspace::GitSpaceMetadata, - trust_repository: bool, -) -> PathBuf { - if !space.is_linked_worktree { - return space.repo_root.clone(); - } - - crate::worktree::list_existing_worktrees(&space.repo_root, trust_repository) - .ok() - .and_then(|entries| { - entries.into_iter().find_map(|entry| { - let entry_space = crate::workspace::git_space_metadata(&entry.path)?; - if entry_space.key == space.key && !entry_space.is_linked_worktree { - Some(entry_space.repo_root) - } else { - None - } - }) - }) - .unwrap_or_else(|| space.repo_root.clone()) -} - fn worktree_membership( source: &WorktreeSource, checkout_path: PathBuf, @@ -745,7 +556,8 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use crate::api::schema::{ - ErrorResponse, Request, SuccessResponse, WorktreeCreateParams, WorktreeRemoveParams, + ErrorResponse, Request, SuccessResponse, WorktreeCreateParams, WorktreeListParams, + WorktreeRemoveParams, }; use crate::events::{ ApiWorktreeAddRequest, ApiWorktreeRemoveRequest, AppEvent, WorktreeAddResult, @@ -883,7 +695,7 @@ mod tests { fn run_deferred_api_request(app: &mut App, request: Request) -> String { let (respond_to, response_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request, respond_to)); + assert!(app.handle_deferred_worktree_api_request(request, respond_to, false)); if let Ok(response) = response_rx.recv_timeout(std::time::Duration::from_millis(50)) { return response; } @@ -1025,6 +837,7 @@ mod tests { }), }, respond_to, + false, )); assert!(response_rx.try_recv().is_err()); @@ -1100,7 +913,7 @@ mod tests { }; let (response_tx, response_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), response_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), response_tx, false)); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); let response = response_rx @@ -1165,7 +978,7 @@ mod tests { }; let (first_tx, first_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), first_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), first_tx, false)); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); let response = first_rx @@ -1176,7 +989,7 @@ mod tests { assert!(app.pending_api_worktree_creates.is_empty()); let (second_tx, second_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), second_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), second_tx, false)); assert!(second_rx.try_recv().is_err()); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); @@ -1345,15 +1158,18 @@ mod tests { let repo = create_committed_repo("api-worktree-open-invalid-cwd-repo"); let mut app = test_app(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - cwd: Some(repo.display().to_string()), - path: Some("/tmp/one".into()), - branch: Some("worktree/one".into()), - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + cwd: Some(repo.display().to_string()), + path: Some("/tmp/one".into()), + branch: Some("worktree/one".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "invalid_request"); @@ -1436,15 +1252,19 @@ mod tests { app.state.workspaces.push(child); app.state.ensure_test_terminals(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - branch: Some("worktree/api-open".into()), - focus: true, - ..WorktreeOpenParams::default() - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + branch: Some("worktree/api-open".into()), + focus: true, + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { @@ -1484,6 +1304,114 @@ mod tests { let _ = std::fs::remove_dir_all(repo); } + #[test] + fn api_worktree_open_preserves_explicit_membership_after_shell_cd() { + let repo = create_committed_repo("api-worktree-membership-repo"); + let checkout = unique_temp_path("api-worktree-membership-checkout"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "worktree/membership", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + // Use Git's path spelling, as worktree create/open do when assigning membership. + let checkout = crate::worktree::list_existing_worktrees(&repo, false) + .unwrap() + .into_iter() + .find(|entry| entry.branch.as_deref() == Some("worktree/membership")) + .unwrap() + .path; + let mut app = app_with_parent(&repo); + let parent_id = app.state.workspaces[0].id.clone(); + let source = app + .resolve_worktree_source(Some(parent_id.clone()), None) + .unwrap_or_else(|err| panic!("{}", err.message)); + app.mark_worktree_membership(&source, 0, repo.clone(), false, false); + let parent_membership = app.state.workspaces[0].worktree_space().cloned(); + + // Shell navigation and refreshed Git metadata must not override provenance. + let parent = &mut app.state.workspaces[0]; + parent.cached_git_space = crate::workspace::git_space_metadata(&checkout); + let terminal_id = parent.tabs[0] + .terminal_id(parent.tabs[0].root_pane) + .unwrap(); + app.state.terminals.get_mut(terminal_id).unwrap().cwd = checkout.clone(); + assert_eq!(app.open_workspace_idx_for_checkout(&checkout), None); + + let mut child = Workspace::test_new("child"); + child.identity_cwd = checkout.clone(); + let child_id = child.id.clone(); + app.state.workspaces.push(child); + app.state.ensure_test_terminals(); + app.mark_worktree_membership(&source, 1, checkout.clone(), true, false); + let child_membership = app.state.workspaces[1].worktree_space().cloned(); + + let response = run_deferred_api_request( + &mut app, + Request { + id: "list".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(parent_id.clone()), + ..WorktreeListParams::default() + }), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::WorktreeList { worktrees, .. } = success.result else { + panic!("expected worktree_list response"); + }; + let entry = worktrees + .iter() + .find(|entry| entry.branch.as_deref() == Some("worktree/membership")) + .unwrap(); + assert_eq!(entry.open_workspace_id.as_deref(), Some(child_id.as_str())); + + let response = run_deferred_api_request( + &mut app, + Request { + id: "open".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(parent_id), + branch: Some("worktree/membership".into()), + focus: false, + ..WorktreeOpenParams::default() + }), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::WorktreeOpened { + workspace, + already_open, + .. + } = success.result + else { + panic!("expected worktree_opened response"); + }; + assert!(already_open); + assert_eq!(workspace.workspace_id, child_id); + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.active, Some(0)); + assert_eq!( + app.state.workspaces[0].worktree_space(), + parent_membership.as_ref() + ); + assert_eq!( + app.state.workspaces[1].worktree_space(), + child_membership.as_ref() + ); + app.state.assert_invariants_for_test(); + + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); + crate::worktree::run_worktree_command(&remove).unwrap(); + let _ = std::fs::remove_dir_all(repo); + } + #[test] fn api_worktree_open_label_on_already_open_checkout_emits_rename_event() { let repo = create_committed_repo("api-worktree-open-label-repo"); @@ -1514,15 +1442,19 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - branch: Some("worktree/api-open-label".into()), - label: Some("review".into()), - ..WorktreeOpenParams::default() - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + branch: Some("worktree/api-open-label".into()), + label: Some("review".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { @@ -1562,6 +1494,233 @@ mod tests { let _ = std::fs::remove_dir_all(repo); } + #[tokio::test] + async fn deferred_worktree_open_rejects_removed_or_replaced_target() { + for replacement in ["missing", "directory", "repository"] { + let repo = create_committed_repo("api-stale-target-source"); + let checkout = unique_temp_path("api-stale-target"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "stale-target", + checkout.to_str().unwrap(), + ], + ); + let mut app = app_with_parent(&repo); + let (respond_to, response_rx) = response_channel(); + let source_id = app.state.workspaces[0].id.clone(); + app.handle_deferred_worktree_api_request( + Request { + id: "stale-target".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id), + branch: Some("stale-target".into()), + label: Some("must not rename".into()), + focus: true, + ..Default::default() + }), + }, + respond_to, + false, + ); + let completion = wait_for_app_event(&mut app); + run_git(&repo, &["worktree", "remove", checkout.to_str().unwrap()]); + match replacement { + "directory" => std::fs::create_dir_all(&checkout).unwrap(), + "repository" => { + let other_repo = create_committed_repo("api-stale-target-replacement"); + std::fs::rename(other_repo, &checkout).unwrap(); + } + _ => {} + } + let mut unrelated = Workspace::test_new("unrelated"); + unrelated.identity_cwd = checkout.clone(); + app.state.workspaces.push(unrelated); + app.state.ensure_test_terminals(); + app.handle_internal_event(completion); + let response: ErrorResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(response.error.code, "worktree_not_found", "{replacement}"); + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.active, Some(0)); + assert!(app + .state + .workspaces + .iter() + .all(|ws| ws.worktree_space().is_none())); + app.state.assert_invariants_for_test(); + let _ = std::fs::remove_dir_all(checkout); + let _ = std::fs::remove_dir_all(repo); + } + } + + #[tokio::test] + async fn deferred_worktree_reads_bound_running_and_queued_discovery() { + let repo = create_committed_repo("api-read-limit"); + let mut app = app_with_parent(&repo); + app.worktree_read_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); + let source_id = app.state.workspaces[0].id.clone(); + let request = |open| Request { + id: "read-limit".into(), + method: if open { + crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id.clone()), + path: Some(repo.display().to_string()), + ..Default::default() + }) + } else { + crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(source_id.clone()), + ..Default::default() + }) + }, + }; + let (entered, release) = crate::worktree::test_list_gate::block(&repo); + let (respond_to, response_rx) = response_channel(); + app.handle_deferred_worktree_api_request(request(false), respond_to, false); + entered + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + let assert_busy = |app: &mut App| { + for open in [false, true] { + let (respond_to, response_rx) = response_channel(); + app.handle_deferred_worktree_api_request(request(open), respond_to, false); + let response: ErrorResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(response.error.code, "worktree_busy"); + } + }; + assert_busy(&mut app); + release.send(()).unwrap(); + let completion = wait_for_app_event(&mut app); + assert_busy(&mut app); + app.handle_internal_event(completion); + let _: SuccessResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(app.worktree_read_slots.available_permits(), 1); + let response = run_deferred_api_request(&mut app, request(false)); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(app.worktree_read_slots.available_permits(), 1); + let _ = std::fs::remove_dir_all(repo); + } + + #[tokio::test] + async fn deferred_worktree_open_rechecks_source_identity_and_existing_target() { + let repo = create_committed_repo("api-worktree-open-pending-source"); + let other_repo = create_committed_repo("api-worktree-open-unrelated-source"); + let checkout = unique_temp_path("api-worktree-open-pending-target"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "pending-open", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + + for source_change in ["reordered", "removed", "repurposed"] { + let mut app = app_with_parent(&repo); + let source_id = app.state.workspaces[0].id.clone(); + let (respond_to, response_rx) = response_channel(); + assert!(app.handle_deferred_worktree_api_request( + Request { + id: "pending-open".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id.clone()), + branch: Some("pending-open".into()), + focus: false, + ..Default::default() + }), + }, + respond_to, + false + )); + let completed_discovery = wait_for_app_event(&mut app); + assert!(response_rx.try_recv().is_err()); + assert!(app.state.workspaces[0].worktree_space().is_none()); + + let original_source = app.state.workspaces.remove(0); + let mut unrelated = Workspace::test_new("unrelated"); + unrelated.identity_cwd = other_repo.clone(); + if source_change == "repurposed" { + unrelated.id = source_id.clone(); + } + let mut opened_target = Workspace::test_new("opened-during-discovery"); + opened_target.identity_cwd = checkout.clone(); + let target_id = opened_target.id.clone(); + app.state.workspaces = vec![unrelated, opened_target]; + if source_change == "reordered" { + app.state.workspaces.push(original_source); + } + app.state.terminals.clear(); + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.handle_internal_event(completed_discovery); + + let response: SuccessResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + let ResponseResult::WorktreeOpened { + workspace, + already_open, + .. + } = response.result + else { + panic!("expected opened response"); + }; + assert!( + already_open, + "target opened during discovery must be reused" + ); + assert_eq!(workspace.workspace_id, target_id); + assert!( + app.state.workspaces[0].worktree_space().is_none(), + "{source_change} source must not mark an unrelated workspace" + ); + assert_eq!( + app.state.active, + Some(0), + "background open must preserve focus" + ); + assert_eq!(app.state.workspaces.len(), 3); + assert_eq!( + crate::worktree::canonical_or_original( + &app.state.workspaces[2].worktree_space().unwrap().repo_root + ), + crate::worktree::canonical_or_original(&repo) + ); + app.state.assert_invariants_for_test(); + } + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); + crate::worktree::run_worktree_command(&remove).unwrap(); + let _ = std::fs::remove_dir_all(repo); + let _ = std::fs::remove_dir_all(other_repo); + } + #[tokio::test] async fn api_worktree_open_source_checkout_created_by_request_is_not_already_open() { let repo = create_committed_repo("api-worktree-open-source-repo"); @@ -1569,15 +1728,18 @@ mod tests { let mut app = test_app_with_event_hub(event_hub.clone()); app.state.default_shell = test_shell().into(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - cwd: Some(repo.display().to_string()), - path: Some(repo.display().to_string()), - label: Some("source checkout".into()), - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + cwd: Some(repo.display().to_string()), + path: Some(repo.display().to_string()), + label: Some("source checkout".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap_or_else(|err| { panic!("expected success response, got {response}: {err}"); @@ -1637,14 +1799,18 @@ mod tests { app.state.workspaces.push(child); app.state.ensure_test_terminals(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - cwd: None, - trust_repository: false, - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { @@ -1701,10 +1867,13 @@ mod tests { trust_repository: false, }), ] { - let response = app.handle_api_request(Request { - id: "req".into(), - method, - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method, + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { source, worktrees } = success.result else { panic!("expected worktree_list response"); @@ -1747,14 +1916,18 @@ mod tests { std::fs::remove_dir_all(&checkout).unwrap(); let mut app = app_with_parent(&repo); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - cwd: None, - trust_repository: false, - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { @@ -1973,6 +2146,7 @@ mod tests { }), }, respond_to, + false, )); assert!(response_rx.try_recv().is_err()); @@ -2047,6 +2221,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2058,6 +2233,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2118,6 +2294,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2129,6 +2306,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2165,6 +2343,7 @@ mod tests { }), }, respond_to, + false, )); let response = response_rx @@ -2220,6 +2399,7 @@ mod tests { }), }, respond_to, + false, )); let response = response_rx diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 9adbd70c..0f5ed547 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -16,8 +16,14 @@ impl App { &mut self, request: Request, respond_to: std::sync::mpsc::Sender, + client_local: bool, ) -> bool { match request.method { + crate::api::schema::Method::WorktreeList(_) + | crate::api::schema::Method::WorktreeOpen(_) => { + self.start_api_worktree_read(request, respond_to, client_local); + true + } crate::api::schema::Method::WorktreeCreate(params) => { self.start_api_worktree_create(request.id, params, respond_to); true diff --git a/src/app/api/worktrees/reads.rs b/src/app/api/worktrees/reads.rs new file mode 100644 index 00000000..5024e7f0 --- /dev/null +++ b/src/app/api/worktrees/reads.rs @@ -0,0 +1,339 @@ +use std::path::PathBuf; + +use crate::api::schema::{Method, Request}; +use crate::app::App; +use crate::events::{AppEvent, WorktreeReadData, WorktreeReadResult}; +use crate::workspace::{GitSpaceMetadata, WorktreeSpaceMembership}; + +use super::super::responses::encode_error; +use super::{absolute_user_path, find_worktree_entry, ApiFailure, WorktreeSource}; + +// Capture only source provenance. Filesystem discovery and Git run on the worker; +// workspace indices and the target's open state are resolved again on completion. +struct SourceInput { + workspace_id: Option, + membership: Option, + git_space: Option, + cwd: Option, +} + +impl SourceInput { + fn resolve( + mut self, + allow_linked: bool, + trust_repository: bool, + ) -> Result<(Option, WorktreeSource), ApiFailure> { + let source = if let Some(membership) = self.membership { + if membership.is_linked_worktree && !allow_linked { + return Err(linked_source_error()); + } + let source_checkout_path = if membership.is_linked_worktree { + self.workspace_id = None; + membership.repo_root.clone() + } else { + membership.checkout_path + }; + WorktreeSource { + workspace_idx: None, + source_checkout_path, + source_repo_root: membership.repo_root, + repo_key: membership.key, + repo_name: membership.label, + } + } else { + let space = self + .git_space + .or_else(|| { + self.cwd + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }) + .ok_or_else(|| { + ApiFailure::new( + "not_git_worktree", + if self.workspace_id.is_some() { + "Herdr worktree actions require a workspace inside a Git work tree" + } else { + "Herdr worktree actions require a path inside a Git work tree" + }, + ) + })?; + if space.is_linked_worktree { + if !allow_linked { + return Err(linked_source_error()); + } + self.workspace_id = None; + } + worktree_source_from_space(space, allow_linked, trust_repository) + }; + Ok((self.workspace_id, source)) + } +} + +fn linked_source_error() -> ApiFailure { + ApiFailure::new( + "linked_worktree_source", + "New and open worktree actions start from the repo parent workspace.", + ) +} + +impl App { + fn capture_worktree_read_source( + &self, + workspace_id: &Option, + cwd: &Option, + ) -> Result { + if workspace_id.is_some() && cwd.is_some() { + return Err(ApiFailure::new( + "invalid_request", + "only one of workspace_id or cwd may be supplied", + )); + } + if let Some(cwd) = cwd { + return Ok(SourceInput { + workspace_id: None, + membership: None, + git_space: None, + cwd: Some(absolute_user_path(cwd)?), + }); + } + let ws_idx = if let Some(workspace_id) = workspace_id { + self.parse_workspace_id(workspace_id).ok_or_else(|| { + ApiFailure::new( + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ) + })? + } else { + self.state + .active + .or_else(|| { + self.state + .workspaces + .get(self.state.selected) + .map(|_| self.state.selected) + }) + .ok_or_else(|| { + ApiFailure::new( + "invalid_request", + "workspace_id or cwd is required when no workspace is active", + ) + })? + }; + let ws = &self.state.workspaces[ws_idx]; + Ok(SourceInput { + workspace_id: Some(ws.id.clone()), + membership: ws.worktree_space().cloned(), + git_space: ws.git_space().cloned(), + cwd: if ws.worktree_space().is_none() && ws.git_space().is_none() { + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + } else { + None + }, + }) + } + + pub(super) fn start_api_worktree_read( + &mut self, + request: Request, + respond_to: std::sync::mpsc::Sender, + client_local: bool, + ) { + let (workspace_id, cwd, allow_linked, trust_repository) = match &request.method { + Method::WorktreeList(params) => ( + ¶ms.workspace_id, + ¶ms.cwd, + true, + params.trust_repository, + ), + Method::WorktreeOpen(params) => { + if params.path.is_some() == params.branch.is_some() { + let _ = respond_to.send(encode_error( + request.id, + "invalid_request", + "exactly one of path or branch is required", + )); + return; + } + ( + ¶ms.workspace_id, + ¶ms.cwd, + false, + params.trust_repository, + ) + } + _ => unreachable!("only worktree list/open use background discovery"), + }; + let input = match self.capture_worktree_read_source(workspace_id, cwd) { + Ok(input) => input, + Err(err) => { + let _ = respond_to.send(encode_error(request.id, err.code, err.message)); + return; + } + }; + let Ok(permit) = self.worktree_read_slots.clone().try_acquire_owned() else { + let _ = respond_to.send(encode_error( + request.id, + "worktree_busy", + "too many worktree checks are pending; retry shortly", + )); + return; + }; + let event_tx = self.event_tx.clone(); + let spawn_error_response = respond_to.clone(); + let request_id = request.id.clone(); + let spawned = std::thread::Builder::new() + .name("worktree-read".into()) + .spawn(move || { + let source_cwd = input.cwd.clone(); + let mut source_workspace_id = None; + let result = input + .resolve(allow_linked, trust_repository) + .and_then(|(workspace_id, source)| { + source_workspace_id = workspace_id; + let mut entries = crate::worktree::list_existing_worktrees( + &source.source_repo_root, + trust_repository, + ) + .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; + if let Method::WorktreeOpen(params) = &request.method { + entries = vec![find_worktree_entry( + entries, + params.path.clone(), + params.branch.clone(), + )?]; + } + Ok(WorktreeReadData { + source_checkout_path: source.source_checkout_path, + source_repo_root: source.source_repo_root, + repo_key: source.repo_key, + repo_name: source.repo_name, + entries, + }) + }) + .map_err(|err| (err.code.to_string(), err.message)); + let _ = event_tx.blocking_send(AppEvent::WorktreeReadFinished(Box::new( + WorktreeReadResult { + _permit: permit, + request, + client_local, + source_workspace_id, + source_cwd, + result, + respond_to, + }, + ))); + }); + if let Err(err) = spawned { + let _ = spawn_error_response.send(encode_error( + request_id, + "worktree_list_failed", + format!("could not start worktree discovery: {err}"), + )); + } + } + + pub(crate) fn handle_api_worktree_read_finished(&mut self, result: WorktreeReadResult) { + let response = match result.result { + Err((code, message)) => encode_error(result.request.id, &code, message), + Ok(data) => { + let mut source = WorktreeSource { + workspace_idx: None, + source_checkout_path: data.source_checkout_path, + source_repo_root: data.source_repo_root, + repo_key: data.repo_key, + repo_name: data.repo_name, + }; + // An index captured before Git ran could now name a different workspace. + source.workspace_idx = result + .source_workspace_id + .as_ref() + .and_then(|id| self.state.workspaces.iter().position(|ws| &ws.id == id)) + .filter(|&idx| { + let ws = &self.state.workspaces[idx]; + if let Some(current) = ws.worktree_space() { + !current.is_linked_worktree + && current.key == source.repo_key + && current.repo_root == source.source_repo_root + && current.checkout_path == source.source_checkout_path + } else if let Some(current) = ws.git_space() { + !current.is_linked_worktree + && current.key == source.repo_key + && current.repo_root == source.source_repo_root + } else { + // Git was already discovered on the worker. A cache miss here + // must only compare the captured source path, not rediscover it. + result.source_cwd.as_ref().is_some_and(|cwd| { + ws.resolved_identity_cwd_from( + &self.state.terminals, + &self.terminal_runtimes, + ) + .as_ref() + == Some(cwd) + }) + } + }) + .or_else(|| self.find_parent_workspace_by_key(&source.repo_key)) + .or_else(|| self.open_workspace_idx_for_checkout(&source.source_checkout_path)); + match result.request.method { + Method::WorktreeList(_) => { + self.finish_worktree_list(result.request.id, source, data.entries) + } + Method::WorktreeOpen(params) => self.finish_worktree_open( + result.request.id, + params, + source, + data.entries + .into_iter() + .next() + .expect("open discovery selects one worktree"), + ), + _ => unreachable!("only worktree list/open use background discovery"), + } + } + }; + let _ = result.respond_to.send(response); + } +} + +fn worktree_source_from_space( + space: crate::workspace::GitSpaceMetadata, + allow_linked: bool, + trust_repository: bool, +) -> WorktreeSource { + let source_checkout_path = if allow_linked { + parent_checkout_path_for_space(&space, trust_repository) + } else { + space.repo_root.clone() + }; + WorktreeSource { + workspace_idx: None, + source_checkout_path: source_checkout_path.clone(), + source_repo_root: source_checkout_path, + repo_key: space.key, + repo_name: space.repo_name, + } +} + +fn parent_checkout_path_for_space( + space: &crate::workspace::GitSpaceMetadata, + trust_repository: bool, +) -> PathBuf { + if !space.is_linked_worktree { + return space.repo_root.clone(); + } + + crate::worktree::list_existing_worktrees(&space.repo_root, trust_repository) + .ok() + .and_then(|entries| { + entries.into_iter().find_map(|entry| { + let entry_space = crate::workspace::git_space_metadata(&entry.path)?; + if entry_space.key == space.key && !entry_space.is_linked_worktree { + Some(entry_space.repo_root) + } else { + None + } + }) + }) + .unwrap_or_else(|| space.repo_root.clone()) +} diff --git a/src/app/creation.rs b/src/app/creation.rs index af34e540..05736fe5 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -340,6 +340,7 @@ impl App { foreground_cwd: ws.tabs[tab_idx] .foreground_cwd_for_pane(pane_id, &self.terminal_runtimes) .map(|cwd| cwd.display().to_string()), + restore_error: terminal.restore_error.clone(), label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), title: presentation.title, diff --git a/src/app/mod.rs b/src/app/mod.rs index 77279d5e..78e92979 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,7 +17,6 @@ mod creation; mod custom_commands; mod git_refresh; mod ids; -pub(crate) mod pane_graphics; mod popup; mod runtime; mod session; @@ -103,9 +102,6 @@ impl AppPolicy { pub struct App { pub state: AppState, - pub(crate) pane_graphics: pane_graphics::Runtime, - pub(crate) pane_graphics_files: Arc, - pub(crate) direct_graphics_available: bool, pub(crate) pixel_mouse_available: bool, pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry, pub event_tx: mpsc::Sender, @@ -124,6 +120,7 @@ pub struct App { pub(crate) git_identity_refresh_requested: bool, pub(crate) git_status_cache: HashMap, pub(crate) pending_api_worktree_creates: HashMap, + pub(crate) worktree_read_slots: std::sync::Arc, pub(crate) pending_api_worktree_removes: HashMap, pub(crate) pending_api_worktree_remove_paths: HashMap, pub(crate) pending_worktree_remove_runtime_exits: HashMap, @@ -136,6 +133,8 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option, pub(crate) pending_agent_resume_deadline: Option, + startup_per_agent_delay: Duration, + next_agent_resume_at: Option, pub(crate) session_save_deadline: Option, pub(crate) session_save_thread: Option>, session_writer: Arc>, @@ -362,7 +361,7 @@ impl App { api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, ) -> Self { - let (prefix_code, prefix_mods) = config.prefix_key(); + let prefix_keys = config.prefix_keys(); crate::kitty_graphics::set_enabled(config.kitty_graphics_enabled()); let (event_tx, event_rx) = mpsc::channel::(APP_EVENT_CHANNEL_CAPACITY); let render_notify = Arc::new(Notify::new()); @@ -480,8 +479,7 @@ impl App { toast: None, pending_agent_notifications: std::collections::HashMap::new(), outer_terminal_focus: None, - prefix_code, - prefix_mods, + prefix_keys, headless_size: config.headless_size(), agent_panel_sort, agent_view_override: None, @@ -572,9 +570,6 @@ impl App { toast_deadline: None, last_api_notification_at: None, state, - pane_graphics: pane_graphics::Runtime::default(), - pane_graphics_files: Arc::new(crate::pane_graphics_files::FileStore::default()), - direct_graphics_available: false, pixel_mouse_available: false, terminal_runtimes: restored_terminal_runtimes, event_tx, @@ -586,6 +581,7 @@ impl App { git_identity_refresh_requested: false, git_status_cache: HashMap::new(), pending_api_worktree_creates: HashMap::new(), + worktree_read_slots: std::sync::Arc::new(tokio::sync::Semaphore::new(8)), pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), pending_worktree_remove_runtime_exits: HashMap::new(), @@ -600,6 +596,10 @@ impl App { loaded_host_cursor: config.ui.host_cursor, agent_metadata_deadline: None, pending_agent_resume_deadline: None, + startup_per_agent_delay: Duration::from_millis( + config.session.startup_per_agent_delay_ms.into(), + ), + next_agent_resume_at: None, session_save_deadline: None, session_save_thread: None, session_writer, @@ -798,8 +798,7 @@ impl App { if !invalid_section("keys") { match config.live_keybinds_with_diagnostics() { Ok((live, keybind_diagnostics)) => { - self.state.prefix_code = live.prefix.0; - self.state.prefix_mods = live.prefix.1; + self.state.prefix_keys = live.prefix; self.state.keybinds = live.keybinds; match config.local_keybindings_profile_toml() { Ok(profile) => self.client_shell_keybindings_profile = Some(profile), @@ -857,6 +856,16 @@ impl App { } } + if !invalid_section("session") + && Duration::from_millis(config.session.startup_per_agent_delay_ms.into()) + != self.startup_per_agent_delay + { + diagnostics.push( + "session.startup_per_agent_delay_ms changes require restarting Herdr; kept current setting" + .into(), + ); + } + let graphics_config_valid = !invalid_section("terminal") && (config.terminal.kitty_graphics.is_some() || !invalid_section("experimental")); if graphics_config_valid @@ -1695,8 +1704,10 @@ mod tests { assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert_eq!(app.state.headless_size, (160, 50)); - assert_eq!(app.state.prefix_code, KeyCode::Char('a')); - assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); assert!(app .state .keybinds @@ -1760,6 +1771,26 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + #[test] + fn reload_config_reports_startup_delay_requires_restart() { + let mut app = test_app(); + let mut config = Config::default(); + let report = app.apply_live_config(&config, &[], &[], false); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + + config.session.startup_per_agent_delay_ms = 250; + let report = app.apply_live_config(&config, &[], &[], false); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); + assert_eq!(app.startup_per_agent_delay, Duration::from_millis(100)); + assert_eq!(report.diagnostics, vec![ + "session.startup_per_agent_delay_ms changes require restarting Herdr; kept current setting" + ]); + + let report = app.apply_live_config(&config, &[], &["session".into()], false); + assert!(report.diagnostics.is_empty()); + assert_eq!(app.startup_per_agent_delay, Duration::from_millis(100)); + } + #[test] fn reload_config_requests_client_reload_for_key_only_change() { let _guard = config_env_lock().lock().unwrap(); @@ -1773,7 +1804,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.prefix_code, KeyCode::Char('a')); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); assert!(app.state.request_client_config_reload); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -1928,17 +1962,14 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_prefix = app.state.prefix_keys.clone(); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); assert!(report.diagnostics.iter().any(|diagnostic| { diagnostic.contains("keys.new_workspace") && diagnostic.contains("disabling binding") })); - assert_eq!( - (app.state.prefix_code, app.state.prefix_mods), - original_prefix - ); + assert_eq!(app.state.prefix_keys, original_prefix); assert!(app.state.keybinds.new_workspace.bindings.is_empty()); assert_eq!( app.state.toast_config.delivery, @@ -1996,8 +2027,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.prefix_code, KeyCode::Char(' ')); - assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char(' '), KeyModifiers::CONTROL)] + ); assert!(app .state .keybinds @@ -2086,16 +2119,13 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_prefix = app.state.prefix_keys.clone(); let original_keybinds = app.state.keybinds.new_workspace.clone(); let original_toast_delivery = app.state.toast_config.delivery; let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed); - assert_eq!( - (app.state.prefix_code, app.state.prefix_mods), - original_prefix - ); + assert_eq!(app.state.prefix_keys, original_prefix); assert_eq!(app.state.keybinds.new_workspace, original_keybinds); assert_eq!(app.state.toast_config.delivery, original_toast_delivery); assert!(app diff --git a/src/app/pane_graphics.rs b/src/app/pane_graphics.rs deleted file mode 100644 index 71f998d8..00000000 --- a/src/app/pane_graphics.rs +++ /dev/null @@ -1,350 +0,0 @@ -use std::collections::HashMap; -use std::hash::{Hash, Hasher}; - -use crate::api::schema::{PaneGraphicsFormat, PaneGraphicsPlacementParams}; -use crate::layout::PaneId; - -pub(crate) type Key = (PaneId, String); - -pub(crate) const DIRECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -pub(crate) const DIRECT_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); -pub(crate) const DIRECT_OUTER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(9); - -#[derive(Debug)] -pub(crate) enum Backing { - Inline(Vec), - Direct(crate::pane_graphics_files::Lease), - Resident { len: usize, client_id: u64 }, -} - -#[derive(Debug)] -pub(crate) struct Layer { - pub(crate) format: PaneGraphicsFormat, - pub(crate) image_width: u32, - pub(crate) image_height: u32, - pub(crate) backing: Backing, - pub(crate) data_fingerprint: u64, - pub(crate) render: PaneGraphicsPlacementParams, - pub(crate) z_index: i32, -} - -#[derive(Debug)] -pub(crate) struct DirectGate { - pub(crate) transfer_id: u64, - pub(crate) image_id: u32, - pub(crate) client_id: u64, - pub(crate) deadline: std::time::Instant, - pub(crate) written: bool, - pub(crate) success_response: String, - pub(crate) respond_to: std::sync::mpsc::Sender, -} - -#[derive(Debug)] -pub(crate) struct Slot { - pub(crate) host_image_id: u32, - pub(crate) layer: Option, - pub(crate) stream_owner: Option, - pub(crate) stream_active: Option>, - pub(crate) direct_gate: Option, -} - -impl Slot { - #[cfg(test)] - pub(crate) fn test(host_image_id: u32, layer: Option) -> Self { - Self { - host_image_id, - layer, - stream_owner: None, - stream_active: None, - direct_gate: None, - } - } - - pub(crate) fn stream_is_active(&self) -> bool { - self.stream_active - .as_ref() - .is_some_and(|active| active.load(std::sync::atomic::Ordering::Acquire)) - } - - pub(crate) fn direct_client(&self) -> Option { - self.direct_gate - .as_ref() - .map(|gate| gate.client_id) - .or_else(|| self.layer.as_ref().and_then(Layer::resident_client)) - } -} - -impl Drop for Slot { - fn drop(&mut self) { - if let Some(active) = &self.stream_active { - active.store(false, std::sync::atomic::Ordering::Release); - } - } -} - -#[derive(Debug)] -pub(crate) struct Runtime { - pub(crate) slots: HashMap, - revision: u64, - next_host_image_id: u32, -} - -impl Default for Runtime { - fn default() -> Self { - Self { - slots: HashMap::new(), - revision: 0, - next_host_image_id: 1 << 31, - } - } -} - -impl Runtime { - pub(crate) fn revision(&self) -> u64 { - self.revision - } - - pub(crate) fn mark_changed(&mut self) { - self.revision = self.revision.wrapping_add(1); - } - - #[cfg(test)] - pub(crate) fn clear(&mut self) { - self.slots.clear(); - self.mark_changed(); - } - - pub(crate) fn reserve_image_id(&mut self, key: &Key) -> Option { - if let Some(id) = self.slots.get(key).map(|slot| slot.host_image_id) { - return Some(id); - } - for _ in 0..=self.slots.len() { - let id = self.next_host_image_id | (1 << 31); - self.next_host_image_id = id.wrapping_add(1) | (1 << 31); - if self.slots.values().all(|slot| slot.host_image_id != id) { - return Some(id); - } - } - None - } - - pub(crate) fn layer_count(&self, pane_id: PaneId) -> usize { - self.slots.keys().filter(|(id, _)| *id == pane_id).count() - } - - pub(crate) fn can_add_slot(&self, key: &Key) -> bool { - self.slots.contains_key(key) - || self.slots.len() < crate::api::schema::panes::PANE_GRAPHICS_MAX_LAYERS_TOTAL - } - - pub(crate) fn can_store_inline(&self, key: &Key, len: usize) -> bool { - let replaced = self - .slots - .get(key) - .and_then(|slot| slot.layer.as_ref()) - .and_then(Layer::inline_data) - .map_or(0, <[u8]>::len); - self.inline_bytes() - .saturating_sub(replaced) - .saturating_add(len) - <= crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL - } - - fn inline_bytes(&self) -> usize { - self.slots - .values() - .filter_map(|slot| slot.layer.as_ref()) - .filter_map(Layer::inline_data) - .map(<[u8]>::len) - .sum() - } - - pub(crate) fn attach_stream_active( - &mut self, - key: &Key, - owner: &str, - active: std::sync::Arc, - ) { - if let Some(slot) = self - .slots - .get_mut(key) - .filter(|slot| slot.stream_owner.as_deref() == Some(owner)) - { - slot.stream_active = Some(active); - } - } - - pub(crate) fn retain_live_panes(&mut self, state: &crate::app::state::AppState) -> bool { - if self.slots.is_empty() { - return false; - } - let before = self.slots.len(); - self.slots.retain(|(pane_id, _), _| { - state - .workspaces - .iter() - .any(|workspace| workspace.pane_state(*pane_id).is_some()) - }); - if self.slots.len() == before { - return false; - } - self.mark_changed(); - true - } -} - -impl Layer { - pub(crate) fn inline( - format: PaneGraphicsFormat, - image_width: u32, - image_height: u32, - data: Vec, - render: PaneGraphicsPlacementParams, - z_index: i32, - ) -> Self { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - data.hash(&mut hasher); - Self { - format, - image_width, - image_height, - backing: Backing::Inline(data), - data_fingerprint: hasher.finish(), - render, - z_index, - } - } - - pub(crate) fn direct( - image_width: u32, - image_height: u32, - lease: crate::pane_graphics_files::Lease, - render: PaneGraphicsPlacementParams, - z_index: i32, - ) -> Self { - let data_fingerprint = lease.fingerprint(); - Self { - format: PaneGraphicsFormat::Rgba, - image_width, - image_height, - backing: Backing::Direct(lease), - data_fingerprint, - render, - z_index, - } - } - - pub(crate) fn data_len(&self) -> usize { - match &self.backing { - Backing::Inline(data) => data.len(), - Backing::Direct(lease) => lease.len(), - Backing::Resident { len, .. } => *len, - } - } - - pub(crate) fn inline_data(&self) -> Option<&[u8]> { - match &self.backing { - Backing::Inline(data) => Some(data), - Backing::Direct(_) | Backing::Resident { .. } => None, - } - } - - pub(crate) fn direct_lease(&self) -> Option<&crate::pane_graphics_files::Lease> { - match &self.backing { - Backing::Direct(lease) => Some(lease), - Backing::Inline(_) | Backing::Resident { .. } => None, - } - } - - pub(crate) fn mark_resident(&mut self, client_id: u64) -> bool { - let Backing::Direct(lease) = &self.backing else { - return false; - }; - self.backing = Backing::Resident { - len: lease.len(), - client_id, - }; - true - } - - pub(crate) fn resident_client(&self) -> Option { - match self.backing { - Backing::Resident { client_id, .. } => Some(client_id), - Backing::Inline(_) | Backing::Direct(_) => None, - } - } - - pub(crate) fn terminal_only(&self) -> bool { - matches!(self.backing, Backing::Direct(_) | Backing::Resident { .. }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stream_liveness_attaches_only_to_the_exact_owned_layer() { - let mut runtime = Runtime::default(); - let first = (PaneId::from_raw(1), "first".into()); - let second = (PaneId::from_raw(2), "second".into()); - for (key, id) in [(&first, 1), (&second, 2)] { - runtime.slots.insert( - key.clone(), - Slot { - host_image_id: (1 << 31) | id, - layer: None, - stream_owner: Some("shared-owner".into()), - stream_active: None, - direct_gate: None, - }, - ); - } - let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); - - runtime.attach_stream_active(&second, "shared-owner", active.clone()); - - assert!(runtime.slots[&first].stream_active.is_none()); - assert!(std::sync::Arc::ptr_eq( - runtime.slots[&second].stream_active.as_ref().unwrap(), - &active - )); - } - - #[test] - fn runtime_bounds_total_slots_and_inline_bytes() { - let mut runtime = Runtime::default(); - for index in 0..crate::api::schema::panes::PANE_GRAPHICS_MAX_LAYERS_TOTAL { - let key = (PaneId::from_raw(1), format!("layer-{index}")); - runtime - .slots - .insert(key, Slot::test((1 << 31) | index as u32, None)); - } - assert!(!runtime.can_add_slot(&(PaneId::from_raw(2), "extra".into()))); - - let mut runtime = Runtime::default(); - let key = (PaneId::from_raw(1), "primary".into()); - runtime.slots.insert( - key.clone(), - Slot::test( - 1 << 31, - Some(Layer::inline( - PaneGraphicsFormat::Rgba, - 1, - 1, - vec![0; 4], - PaneGraphicsPlacementParams::default(), - 0, - )), - ), - ); - assert!(runtime.can_store_inline( - &key, - crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL, - )); - assert!(!runtime.can_store_inline( - &(PaneId::from_raw(2), "primary".into()), - crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL, - )); - } -} diff --git a/src/app/session.rs b/src/app/session.rs index c952e4d1..8584bb7e 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -28,7 +28,11 @@ impl App { self.state.selected, ); let history = self.persist_pane_history.then(|| { - crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes) + crate::persist::capture_history( + &snapshot, + &self.state.workspaces, + &self.terminal_runtimes, + ) }); crate::persist::checkpoint_session_pair(&snapshot, history.as_ref())?; Ok(()) @@ -72,7 +76,11 @@ impl App { self.state.selected, ); let history = self.persist_pane_history.then(|| { - crate::persist::capture_history(&self.state.workspaces, &self.terminal_runtimes) + crate::persist::capture_history( + &snapshot, + &self.state.workspaces, + &self.terminal_runtimes, + ) }); SessionSaveJob::Save { snapshot, history } } diff --git a/src/app/state.rs b/src/app/state.rs index 892ec5e9..0c094bf9 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -760,6 +760,11 @@ pub struct ReleaseNotesState { pub body: String, pub scroll: u16, pub preview: bool, + /// True when the view shows the stored release-notes slot; closing it + /// dismisses the server-side notes and later snapshots may replace it. + /// The embedded history view sets false: closing sends no endpoint + /// request and snapshots never close or replace it. + pub from_stored_notes: bool, } #[derive(Debug)] @@ -820,8 +825,7 @@ pub struct AppState { /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option, // Config - pub prefix_code: KeyCode, - pub prefix_mods: KeyModifiers, + pub prefix_keys: Vec<(KeyCode, KeyModifiers)>, /// Virtual terminal size (columns, rows) used when no client is attached. pub(crate) headless_size: (u16, u16), pub agent_panel_sort: AgentPanelSort, @@ -1046,8 +1050,7 @@ impl AppState { toast: None, pending_agent_notifications: std::collections::HashMap::new(), outer_terminal_focus: None, - prefix_code: KeyCode::Char('b'), - prefix_mods: KeyModifiers::CONTROL, + prefix_keys: vec![(KeyCode::Char('b'), KeyModifiers::CONTROL)], headless_size: ( crate::config::DEFAULT_HEADLESS_COLS, crate::config::DEFAULT_HEADLESS_ROWS, diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index a8fe394a..bbaaaf7d 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -55,8 +55,7 @@ impl App { .filter(|candidate| { self.state .terminals - .values() - .find(|terminal| terminal.id.to_string() == candidate.terminal_id) + .get(candidate.terminal_id.as_str()) .is_some_and(|terminal| { terminal.agent_name.as_deref() == Some(target) || terminal.effective_agent_label() == Some(target) @@ -91,8 +90,7 @@ impl App { .filter(|candidate| { self.state .terminals - .values() - .find(|terminal| terminal.id.to_string() == candidate.terminal_id) + .get(candidate.terminal_id.as_str()) .is_some_and(|terminal| terminal.agent_name.as_deref() == Some(target)) }) .collect(); @@ -108,8 +106,7 @@ impl App { fn target_is_agent(&self, target: &TerminalTarget) -> bool { self.state .terminals - .values() - .find(|terminal| terminal.id.to_string() == target.terminal_id) + .get(target.terminal_id.as_str()) .is_some_and(|terminal| terminal.is_agent_terminal()) } @@ -193,3 +190,140 @@ impl App { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppPolicy, AppState}; + + fn test_app() -> App { + let (_, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &crate::config::Config::default(), + AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + #[test] + fn named_targets_follow_terminal_identity_after_pane_and_tab_reordering() { + let mut app = test_app(); + app.state = AppState::test_with_adversarial_identity_state(); + let targets = app.terminal_targets(); + for (index, target) in targets.iter().enumerate() { + app.state + .terminals + .get_mut(target.terminal_id.as_str()) + .unwrap() + .set_agent_name(format!("worker-{index}")); + } + for (index, target) in targets.iter().enumerate() { + let name = format!("worker-{index}"); + assert_eq!(app.resolve_terminal_target(&name).unwrap(), *target); + assert_eq!(app.resolve_agent_target(&name).unwrap(), *target); + let pane = app.public_pane_id(target.ws_idx, target.pane_id).unwrap(); + assert_eq!(app.resolve_agent_target(&pane).unwrap(), *target); + } + app.state.assert_invariants_for_test(); + } + + #[test] + fn name_lookup_keeps_pane_order_and_ignores_detached_terminals() { + let mut app = test_app(); + app.state = AppState::test_with_adversarial_identity_state(); + let targets = app.terminal_targets(); + for target in &targets { + app.state + .terminals + .get_mut(target.terminal_id.as_str()) + .unwrap() + .set_agent_name("shared".into()); + } + let detached_id = crate::terminal::TerminalId::alloc(); + let mut detached = + crate::terminal::TerminalState::new(detached_id.clone(), std::env::temp_dir()); + detached.set_agent_name("detached".into()); + app.state.terminals.insert(detached_id, detached); + assert!(matches!( + app.resolve_agent_target("detached"), + Err(TerminalTargetError::NotFound { .. }) + )); + for result in [ + app.resolve_terminal_target("shared"), + app.resolve_agent_target("shared"), + ] { + let Err(TerminalTargetError::Ambiguous { candidates, .. }) = result else { + panic!("expected all attached panes to remain ambiguous"); + }; + assert_eq!( + candidates + .iter() + .map(|candidate| candidate.terminal_id.as_str()) + .collect::>(), + targets + .iter() + .map(|target| target.terminal_id.as_str()) + .collect::>(), + ); + } + } + + #[test] + #[ignore = "manual terminal target lookup scaling profile"] + fn terminal_target_lookup_profile() { + use std::hint::black_box; + use std::time::{Duration, Instant}; + + for count in [1, 15, 128, 512] { + let mut app = test_app(); + let mut workspace = crate::workspace::Workspace::test_new("lookup-profile"); + for _ in 1..count { + workspace.test_split(ratatui::layout::Direction::Horizontal); + } + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let id = app.state.workspaces[0] + .terminal_id(app.state.workspaces[0].tabs[0].root_pane) + .unwrap() + .clone(); + app.state + .terminals + .get_mut(&id) + .unwrap() + .set_agent_name("profile-target".into()); + for (label, agent, target) in [ + ("terminal-name", false, "profile-target"), + ("agent-name", true, "profile-target"), + ("missing", false, "missing-target"), + ] { + let lookup = || { + if agent { + app.resolve_agent_target(target) + } else { + app.resolve_terminal_target(target) + } + }; + for _ in 0..32 { + black_box(lookup()).ok(); + } + let mut samples = Vec::new(); + for _ in 0..7 { + let start = Instant::now(); + let mut iterations = 0; + while start.elapsed() < Duration::from_millis(20) { + black_box(lookup()).ok(); + iterations += 1; + } + samples.push(start.elapsed().as_secs_f64() * 1e6 / f64::from(iterations)); + } + samples.sort_by(f64::total_cmp); + println!( + "terminal-target panes={count} case={label} median_us={:.3}", + samples[3] + ); + } + } + } +} diff --git a/src/build_info.rs b/src/build_info.rs index 5b5d9f6c..89af97f4 100644 --- a/src/build_info.rs +++ b/src/build_info.rs @@ -3,10 +3,7 @@ pub const BASE_VERSION: &str = env!("CARGO_PKG_VERSION"); // Superherdr releases are `.`; reset to 1 when merging a new Herdr base. -pub const SUPERHERDR_REVISION: u32 = 2; - -// Plugin requirements track inherited Herdr capabilities, not Superherdr release numbers. -pub const HERDR_PLUGIN_COMPATIBILITY_VERSION: &str = "0.9.0"; +pub const SUPERHERDR_REVISION: u32 = 1; pub fn channel() -> &'static str { non_empty(option_env!("HERDR_BUILD_CHANNEL")).unwrap_or("stable") diff --git a/src/cli.rs b/src/cli.rs index 20a2f50b..427eaaf2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -782,8 +782,7 @@ pub(super) fn send_request_unchecked(request: &Request) -> std::io::Result std::io::Result<()> { - let status = client - .status() + let status = target::server_status(client) .map_err(|err| map_server_not_running_or_io(err, request_id, client))?; let server_protocol = status .protocol diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 1af3e0d6..09043460 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -610,6 +610,12 @@ fn wait_for_named_agent( "agent_not_ready", format!("agent {name} is blocked during startup and is not ready for prompts"), ))), + Some("unknown") + if expected_kind == "codex" + && agent["interactive_ready"].as_bool() == Some(true) => + { + Some(Ok(agent.clone())) + } Some("working" | "unknown") => None, Some("idle" | "done") if agent["interactive_ready"].as_bool() == Some(true) => { Some(Ok(agent.clone())) diff --git a/src/cli/machine.rs b/src/cli/machine.rs index ab84041e..2459805a 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -1,10 +1,17 @@ +use std::io::IsTerminal as _; + +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::style::{Attribute, SetAttribute}; +use crossterm::{cursor, execute, terminal}; use serde::Serialize; -use crate::client::endpoint::{EndpointCatalog, ProfileId}; +use crate::client::endpoint::{EndpointCatalog, ProfileId, MAX_LABEL_BYTES}; const HELP: &str = "Usage: herdr machine list [--json] - herdr machine add --label