From cee4fc2dc6ef2b9269636fc5dc876ecb44ee039f Mon Sep 17 00:00:00 2001 From: kangal-bot <285672167+kangal-bot@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:38:39 +0000 Subject: [PATCH 01/99] docs: publish preview documentation --- distribution/preview.json | 84 ++++++++++--------- .../src/content/docs/agent-automation.mdx | 4 +- .../website/src/content/docs/agents.mdx | 1 + .../src/content/docs/cli-reference.mdx | 31 +++++-- .../src/content/docs/configuration.mdx | 6 +- .../src/content/docs/connecting-machines.mdx | 14 +++- .../website/src/content/docs/integrations.mdx | 34 ++++++-- .../src/content/docs/ja/agent-automation.mdx | 4 +- .../website/src/content/docs/ja/agents.mdx | 1 + .../src/content/docs/ja/cli-reference.mdx | 23 ++++- .../src/content/docs/ja/configuration.mdx | 2 +- .../content/docs/ja/connecting-machines.mdx | 2 +- .../src/content/docs/ja/integrations.mdx | 34 ++++++-- .../website/src/content/docs/ja/keyboard.mdx | 2 + .../content/docs/ja/persistence-remote.mdx | 2 + .../src/content/docs/ja/session-state.mdx | 5 ++ .../src/content/docs/ja/troubleshooting.mdx | 10 ++- .../website/src/content/docs/keyboard.mdx | 2 + .../src/content/docs/persistence-remote.mdx | 8 +- .../website/src/content/docs/quick-start.mdx | 4 +- .../src/content/docs/session-state.mdx | 7 +- .../website/src/content/docs/socket-api.mdx | 13 ++- .../src/content/docs/troubleshooting.mdx | 10 ++- .../website/src/content/docs/windows-beta.mdx | 7 +- .../content/docs/zh-cn/agent-automation.mdx | 4 +- .../website/src/content/docs/zh-cn/agents.mdx | 1 + .../src/content/docs/zh-cn/cli-reference.mdx | 23 ++++- .../src/content/docs/zh-cn/configuration.mdx | 6 +- .../docs/zh-cn/connecting-machines.mdx | 2 +- .../src/content/docs/zh-cn/integrations.mdx | 34 ++++++-- .../src/content/docs/zh-cn/keyboard.mdx | 2 + .../content/docs/zh-cn/persistence-remote.mdx | 2 + .../src/content/docs/zh-cn/session-state.mdx | 5 ++ .../content/docs/zh-cn/troubleshooting.mdx | 10 ++- .../website/src/data/config-reference.json | 13 ++- 35 files changed, 314 insertions(+), 98 deletions(-) diff --git a/distribution/preview.json b/distribution/preview.json index cf16e1dd..8a4e39b9 100644 --- a/distribution/preview.json +++ b/distribution/preview.json @@ -2,36 +2,67 @@ "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", + "build_id": "2026-09-16-2c29fb29e302", + "commit": "2c29fb29e30220bc48d783af90ba8f53b81fd96c", + "built_at": "2026-09-16T16:22:39Z", "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-16-2c29fb29e302\n\n[View changes](https://github.com/herdrdev/herdr/compare/62431dbd033bf1be2fe87fbc526df28084214e49...2c29fb29e30220bc48d783af90ba8f53b81fd96c)", "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-16-2c29fb29e302/herdr-linux-x86_64", + "sha256": "898c10e7004bafd96a5eb0cbdf417bd10958dae3b436d0249b9d70885653c30c" }, "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-16-2c29fb29e302/herdr-linux-aarch64", + "sha256": "b378263788476bff3815f404695cd73251e236626d215d1e78adeb98641ff0ca" }, "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-16-2c29fb29e302/herdr-macos-x86_64", + "sha256": "07f9c1c6be2bb95eb49778ff34bf81cce4a67c9de4cf228b3252901d9de97b45" }, "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-16-2c29fb29e302/herdr-macos-aarch64", + "sha256": "398ab746369a8573a65fb42b1348df2bf31e22cd4f7d676fca8fca5fcb99a5a4" }, "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-16-2c29fb29e302/herdr-windows-x86_64.zip", + "sha256": "32bb80b9da79b8ab2d8523a340cb50529ca21835128a2bf8ab5ece954f48b8de", "format": "zip" } }, "builds": { + "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", @@ -862,31 +893,6 @@ "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/preview/website/src/content/docs/agent-automation.mdx b/docs/preview/website/src/content/docs/agent-automation.mdx index 068d1c9e..e7c02ae3 100644 --- a/docs/preview/website/src/content/docs/agent-automation.mdx +++ b/docs/preview/website/src/content/docs/agent-automation.mdx @@ -41,7 +41,7 @@ Agent commands accept either a unique live name or the pane ID that currently ho An available shell pane is at its interactive shell prompt: the shell itself owns the foreground, with no foreground command, editor, or agent running. Return the pane to its prompt before calling `agent start`. -`--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `maki`, and `muse`. Arguments after `--` are passed unchanged to that executable. +`--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, `maki`, and `muse`. Arguments after `--` are passed unchanged to that executable. Successful `agent start` returns only after Herdr detects the expected agent in the same terminal and marks it ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. Startup waits for 30 seconds by default; `--timeout` must be greater than 3000 and no more than 300000 milliseconds. @@ -73,7 +73,7 @@ herdr agent rename w1:p2 reviewer Pane input addresses the terminal regardless of its current occupant. Agent input resolves the live agent and rejects the operation if that agent no longer controls the pane. -`agent prompt --wait` rejects an agent already `blocked` with `agent_blocked`, without sending input or starting a wait. Otherwise, it writes the prompt and delayed Enter as one ordered submission before waiting. For Codex on Windows, the delay grows with prompt size. The caller timeout includes submission time. If the prompt started from another non-working state, Herdr waits up to five seconds after submission to observe `working` or `blocked`. Otherwise, it returns `agent_prompt_stalled`; if the caller timeout expires first, it returns the normal `timeout` error. This prevents unrelated `idle`, `done`, or session changes from completing the wait. After Herdr observes activity, it waits for the requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. Standalone `agent wait` observes the current agent and returns immediately if its status already matches. Both commands default to `idle`, `done`, or `blocked`. Repeat `--until` to accept several exact states, for example `--until idle --until done`; use `--until unknown` explicitly when needed. On `agent prompt`, `--until` requires `--wait`. +`agent prompt --wait` rejects an agent already `blocked` with `agent_blocked`, without sending input or starting a wait. Otherwise, it writes the prompt and delayed Enter as one ordered submission before waiting. On Windows, Codex receives a paste boundary before Enter so submission does not depend on prompt size. The caller timeout includes submission time. If the prompt started from another non-working state, Herdr waits up to five seconds after submission to observe `working` or `blocked`. Otherwise, it returns `agent_prompt_stalled`; if the caller timeout expires first, it returns the normal `timeout` error. This prevents unrelated `idle`, `done`, or session changes from completing the wait. After Herdr observes activity, it waits for the requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. Standalone `agent wait` observes the current agent and returns immediately if its status already matches. Both commands default to `idle`, `done`, or `blocked`. Repeat `--until` to accept several exact states, for example `--until idle --until done`; use `--until unknown` explicitly when needed. On `agent prompt`, `--until` requires `--wait`. `idle` and `done` both mean the agent is ready for input. The CLI/API uses the server's seen state: `done` is idle but not yet marked seen, explicit `pane focus` / `agent focus` commands mark the target seen, and reads do not. Each TUI client tracks viewed completions independently, so a client's Done badge can differ from the CLI or another client's badge. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify its lifecycle confidently; it does not prove successful completion. Use exact `--until` states when that distinction matters. diff --git a/docs/preview/website/src/content/docs/agents.mdx b/docs/preview/website/src/content/docs/agents.mdx index eecdb22d..d0612c3f 100644 --- a/docs/preview/website/src/content/docs/agents.mdx +++ b/docs/preview/website/src/content/docs/agents.mdx @@ -21,6 +21,7 @@ Automatic detection works out of the box for common coding agents. The table sho | Hermes Agent | screen manifest | session | | Qoder CLI | screen manifest | session | | Qwen Code | screen manifest | session | +| Letta Code | screen manifest | session | | Droid | screen manifest | session | | OpenCode | lifecycle plugin when installed; otherwise screen manifest | state and session | | Kilo Code CLI | lifecycle plugin when installed; otherwise screen manifest | state and session | diff --git a/docs/preview/website/src/content/docs/cli-reference.mdx b/docs/preview/website/src/content/docs/cli-reference.mdx index 63642729..9f61d02d 100644 --- a/docs/preview/website/src/content/docs/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/cli-reference.mdx @@ -66,7 +66,22 @@ Changes apply automatically to open local Herdr clients, normally within a secon Automatic connections and reconnects are non-interactive. If a host key, password, key passphrase, MFA step, install, update, or restart needs approval, the machine shows Attention instead of opening a hidden prompt. Run the standalone command printed by Herdr, such as `herdr --remote workbox`, to complete that setup in the foreground, then restart the client. Include `--session ` only when the profile targets a named session. -Workspace, tab, pane IDs, and agent names belong to a single server. Selecting a machine in the UI does not change the session or socket inherited by commands running in an existing pane. For remote automation, run the CLI on the intended host against the intended session and discover its IDs there. +Workspace, tab, pane IDs, and agent names belong to a single server. Selecting a machine in the UI does not retarget CLI commands. Use the global **prefix** `--machine ` to route API commands to a saved SSH machine: + +```bash +herdr --machine "Build machine" agent list +herdr --machine pane list +herdr --machine "Build machine" agent prompt w1:p1 "review this change" +herdr --machine "Build machine" worktree create --cwd /srv/project --branch review +``` + +The selector must match an enabled saved profile ID or a unique, case-sensitive label, not an arbitrary SSH hostname. The saved remote session is used; combining `--machine` with `--session` or `--remote` is an error. Without the prefix, commands retain their existing local session/socket behavior. + +Supported commands are `workspace`, `worktree`, `tab`, `pane`, `notification`, `agent` (except `attach` and local `explain --file`), `api snapshot`, `status server`, and API-backed plugin commands (`link`, `unlink`, `enable`, `disable`, `list`, `action`, `log`, `pane`). Server commands support `stop`, `reload-config`, `agent-manifests`, and `reload-agent-manifests`. Local installation/configuration commands, plugin installation, session management, and interactive terminal attachment are not forwarded. + +Requests and responses travel through the JSON API over non-interactive SSH; API payloads are not interpolated into the SSH shell command. No open TUI is required, and the bridge never installs, starts, or restarts a server. Update the local CLI and remote Herdr installation to support machine forwarding, and keep the running remote server's API protocol compatible. Authentication, unavailable machines, and incompatible versions fail without falling back to Local. Requests are not automatically retried after connection failure. + +Local pane IDs are not inherited by remote commands. Use explicit remote IDs; `--current` cannot refer to the caller's local pane. Remote worktree paths must be absolute, `~`, or start with `~/` (expanded on the server); plugin link paths must be absolute. The prefix targets one machine at a time; combined listings and routing through another TUI's connections are not included. ## Shell completions @@ -210,8 +225,9 @@ herdr pane close For pane commands that accept `--current`, Herdr uses the calling pane's `HERDR_PANE_ID` when the command runs inside a Herdr pane. For `pane split`, -an explicit pane id or `--pane ID` splits that pane, `--current` splits the -calling pane, and an omitted target keeps using the UI-focused pane. +an explicit pane id or `--pane ID` splits that pane. An omitted target splits +the calling pane when `HERDR_PANE_ID` is available, otherwise the focused pane. +`--current` requires a calling pane and errors when `HERDR_PANE_ID` is unavailable. The split response exposes the new pane ID as `.result.pane.pane_id`. `pane input --right-click pane` forwards unmodified right-click gestures to a @@ -329,11 +345,11 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] Agent targets are either a unique live agent name or the pane ID that currently hosts the agent. Terminal IDs and bare agent-kind labels are not agent targets. Agents started through `agent start` require a name; manually launched agents remain unnamed and use their pane ID. -`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `maki`, and `muse`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. +`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, `maki`, and `muse`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. A successful start returns only after the expected agent owns the same terminal and is ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. -`agent prompt` honors live bracketed-paste mode and writes text followed by delayed Enter as one ordered submission, including while the agent is working. Success without `--wait` acknowledges the writes, not the start of a turn. For Codex on Windows, the delay grows with prompt size; the caller timeout includes submission time. If the agent is already `blocked`, it returns `agent_blocked` without sending input. With `--wait`, a prompt sent from another non-working state has up to five seconds after submission to produce an observed `working` or `blocked` state or Herdr returns `agent_prompt_stalled`; if the caller timeout expires first, Herdr returns the normal `timeout` error. This prevents unrelated `idle`, `done`, or session changes from completing the wait. After activity is observed, it waits for the first requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. `--until` narrows the matching states and is rejected unless `--wait` is also present. Standalone `agent wait` returns immediately when the current status matches. Both default to `idle`, `done`, or `blocked`; use `--until unknown` explicitly when needed. +`agent prompt` honors live bracketed-paste mode and writes text followed by delayed Enter as one ordered submission, including while the agent is working. Success without `--wait` acknowledges the writes, not the start of a turn. On Windows, Codex receives a paste boundary before Enter so submission does not depend on prompt size; the caller timeout includes submission time. If the agent is already `blocked`, it returns `agent_blocked` without sending input. With `--wait`, a prompt sent from another non-working state has up to five seconds after submission to produce an observed `working` or `blocked` state or Herdr returns `agent_prompt_stalled`; if the caller timeout expires first, Herdr returns the normal `timeout` error. This prevents unrelated `idle`, `done`, or session changes from completing the wait. After activity is observed, it waits for the first requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. `--until` narrows the matching states and is rejected unless `--wait` is also present. Standalone `agent wait` returns immediately when the current status matches. Both default to `idle`, `done`, or `blocked`; use `--until unknown` explicitly when needed. `idle` and `done` both mean ready for input. The CLI/API uses the server's seen state: `done` is idle but not yet marked seen, explicit `pane focus` / `agent focus` commands mark the target seen, and reads do not. Each TUI client tracks viewed completions independently, so its Done badge can differ from the CLI or another client. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify it confidently, not that its work succeeded. @@ -365,6 +381,9 @@ terminal, or agent target. It prints newline-delimited JSON `terminal.frame` records with base64-encoded ANSI bytes, then a `terminal.closed` record when the server closes the stream. Multiple observers can watch the same terminal without taking input, resize, scroll, or takeover authority. +On Linux and macOS, an observer is disconnected if a socket write makes no progress +for 30 seconds. A stalled stream can end without a final `terminal.closed` record; +the pane and other clients keep running. Quiet panes do not trigger this timeout. `terminal title clear` hands the outer terminal window title back to `ui.window_title`. ## Output waits @@ -399,6 +418,7 @@ herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install mastracode herdr integration install grok @@ -415,6 +435,7 @@ herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall grok diff --git a/docs/preview/website/src/content/docs/configuration.mdx b/docs/preview/website/src/content/docs/configuration.mdx index a8050d2b..1975a43a 100644 --- a/docs/preview/website/src/content/docs/configuration.mdx +++ b/docs/preview/website/src/content/docs/configuration.mdx @@ -415,7 +415,7 @@ rows = [ ] ``` -The first matching rule wins. Its specified style fields override the occurrence's defaults; unspecified fields inherit them. If no rule matches, the defaults remain. Matching uses the full token value before display truncation and does not change its text, separators, or visibility. A rule with no style fields stops matching and keeps the defaults. +The first matching rule wins. Its specified style fields override the occurrence's defaults; unspecified fields inherit them. If no rule matches, the defaults remain. Matching uses the full token value before display truncation. Set `hide = true` on a rule to remove the matching token and its separator; rows with no remaining tokens also disappear. For example, `{ token = "machine", fg = "#61afef", rules = [{ equals = "Local", hide = true }] }` hides the machine token when its label is exactly `Local`. This compares the label, not the connection type. With `hide = false` or omitted, the token stays visible. A rule with no overrides still stops matching and keeps the defaults. `equals`, `contains`, and `starts_with` take strings and are case-sensitive. Add `ignore_case = true` for ASCII case-insensitive matching; non-ASCII characters remain case-sensitive. Empty strings follow ordinary string matching: empty `equals` matches only an empty value, while empty `contains` or `starts_with` matches any present value. @@ -512,7 +512,9 @@ Search the [Config reference](/docs/config-reference/) for scrollback limits, ne ## Kitty graphics -Herdr renders pane images by default in compatible outer terminals. To disable graphics rendering and the pane graphics API: +Herdr renders pane images by default in compatible outer terminals. Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. + +To disable graphics rendering and the pane graphics API: ```toml [terminal] diff --git a/docs/preview/website/src/content/docs/connecting-machines.mdx b/docs/preview/website/src/content/docs/connecting-machines.mdx index e922e3a2..3a31639d 100644 --- a/docs/preview/website/src/content/docs/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/connecting-machines.mdx @@ -43,7 +43,13 @@ Run `herdr` to open the UI. If a local client is already open, added and enabled Choose a machine or one of its workspaces in the sidebar. The selected machine receives your pane input and terminal size, and supplies the visible terminal content and graphics. Other connected machines keep updating their workspace information, agent states, and notifications without streaming their pane screens. -Local opens immediately on startup without waiting for SSH connections. A stalled machine cannot hold up another machine's input. Multiple Herdr clients can also view different tabs on the same server independently; see [Client and server](/docs/concepts/#client-and-server) for shared-tab sizing. +Click the arrow beside any machine to collapse or expand its workspace list without switching away from your current workspace. This also works while that machine is reconnecting. + +For keyboard navigation, press `prefix+w`, then use the workspace navigation keys (Up/Down by default) to highlight workspaces across connected machines in sidebar order. Enter activates the highlighted workspace; Esc or the prefix key cancels without switching. Compact and expanded sidebars reveal the highlighted row, including under a collapsed machine. On desktop, navigation wraps at the ends; the mobile switcher stops at the first or last workspace. Disconnected machines are skipped. + +When highlighting a workspace on another machine, press Enter before using other keyboard actions. This prevents pane, tab, workspace, or custom-command shortcuts from acting on the current machine by mistake. Clicking cancels a remote desktop keyboard preview. Expanded sidebars respect each machine's collapsed worktree groups. + +Local opens immediately on startup without waiting for SSH connections. Selecting Local also cancels an unfinished remote switch without waiting for the remote machine to reply. If Local itself is reconnecting, your selection resumes when it is ready. Local accepts input once its fresh screen and terminal settings are ready. A stalled machine cannot hold up another machine's input. Multiple Herdr clients can also view different tabs on the same server independently; see [Client and server](/docs/concepts/#client-and-server) for shared-tab sizing. When a connection is lost, the last workspace and agent state remains visible but dimmed. That is cached information, not live state. Input and navigation into those cached panes stay disabled until a fresh connection and matching screen arrive. Reconnecting never takes selection away from the machine you are using. @@ -67,10 +73,12 @@ Removing or disabling the machine you are viewing returns you to Local. If Local ## Connection problems -- **Reconnecting:** Herdr retries with bounded backoff after a network interruption, sleep, or SSH failure. SSH connections are checked for application-level activity and probed when quiet, so a broken connection does not stay Online indefinitely. Local detects native connection closure or failure instead of using remote health probes. +- **Reconnecting:** Herdr retries automatically after a network interruption, sleep, or SSH failure. Repeated failures increase the delay up to two minutes; brief successful connections do not reset it. A connection must remain healthy for a minute before the next interruption gets a fast retry. SSH connections are checked for application-level activity and probed when quiet, so a broken connection does not stay Online indefinitely. Local detects native connection closure or failure instead of using remote health probes. - **Attention:** The target needs an action that cannot be completed in the background, such as host-key approval, authentication, or a compatible server. Other machines remain usable. - **Saved-machine file error:** An unreadable or invalid catalog leaves current connections unchanged. Herdr shows a notice and automatically retries reading it. +When both installations support bridge idle cleanup, saved-machine connections to Linux and macOS also close their remote bridge after a minute without traffic in either direction. Sleep counts toward that deadline, which is checked when the host wakes. Quiet healthy connections exchange health checks; watching continuous output does not require typing. Cleanup leaves the remote Herdr server, sessions, and pane processes running. Older installations remain compatible without this optional cleanup. + Background connections never answer prompts or install, update, restart, or hand off a server. For Attention, run the standalone setup command shown by Herdr in an interactive terminal, for example: ```bash @@ -87,7 +95,7 @@ The UI uses the client's local theme, sidebar settings, and keybindings by defau Default agent rows show a `machine` token when multiple machines are present. Existing custom rows are preserved; add `machine` explicitly if you want that label in your layout. [Sidebar row layouts](/docs/configuration/#sidebar-row-layouts) also support conditional colors for machine labels. -Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines may both contain `w1:p1` or an agent named `reviewer`. Selecting a machine in the UI does not retarget CLI commands running in an existing pane: they still use that pane's inherited session and socket. For remote automation, run commands on the intended host against the intended session and read its IDs there. +Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines may both contain `w1:p1` or an agent named `reviewer`. Selecting a machine in the UI does not retarget CLI commands running in an existing pane: they still use that pane's inherited session and socket. For remote automation, use `herdr --machine agent list`, then pass the same prefix when controlling those remote IDs. The CLI uses the saved profile's SSH target and session directly; it does not need an open TUI. Without `--machine`, existing session/socket routing is unchanged. See [CLI reference](/docs/cli-reference/#saved-ssh-machines) for supported commands, update requirements, and remote path rules. ## Updates and saved data diff --git a/docs/preview/website/src/content/docs/integrations.mdx b/docs/preview/website/src/content/docs/integrations.mdx index 1f64f2b9..4516061f 100644 --- a/docs/preview/website/src/content/docs/integrations.mdx +++ b/docs/preview/website/src/content/docs/integrations.mdx @@ -1,6 +1,6 @@ --- title: Integrations -description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. --- Herdr detects supported agents automatically. Install official integrations when you want native agent session restore, direct lifecycle reports, or both. See [Agents](/docs/agents/) for the full status authority model. @@ -23,6 +23,7 @@ herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install mastracode herdr integration install antigravity-cli @@ -45,12 +46,15 @@ herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall mastracode herdr integration uninstall antigravity-cli herdr integration uninstall grok ``` +Herdr saves integration configs atomically on Linux/macOS and for new files; existing Windows files keep their permissions through a backup-first, in-place update. If Windows reports a backup, close the agent and inspect the config before retrying: discard—not restore—an unfinished `.herdr-backup.pending` only after verifying the config, or restore completed `.herdr-backup` contents into the existing file if needed (if missing, establish its intended permissions first), then verify the config and remove the backup. + ## How Herdr uses integrations Herdr uses integrations in two ways: @@ -58,7 +62,7 @@ Herdr uses integrations in two ways: | Integration type | Agents | Effect | | --- | --- | --- | | Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. | -| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | +| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable. @@ -89,9 +93,9 @@ Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that n [Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world example. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, preserves report ordering across sessions, and releases authority on exit. -Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. ## Pi @@ -137,6 +141,8 @@ herdr integration install claude The hook reports Claude Code session identity to the local Herdr socket on session start. Claude Code state comes from Herdr's screen manifest detection. +Claude integration v10 matches only the documented `SessionStart` sources: `startup`, `resume`, `clear`, `compact`, and `fork`. This prevents Grok's `new` and `load` events from launching the imported Herdr Claude hook, without disabling unrelated Claude-compatible hooks. Reinstall the Claude integration after upgrading to migrate the previous wildcard matcher. New Claude source values require an integration update. + Herdr uses `~/.claude` by default, or `CLAUDE_CONFIG_DIR` when set. The Claude config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script. ## Codex @@ -215,7 +221,11 @@ Install the OpenCode plugin: herdr integration install opencode ``` -Herdr writes the plugin to `~/.config/opencode/plugins/herdr-agent-state.js`. The OpenCode config directory must already exist. Uninstall removes only that plugin file. +The integration supports OpenCode V1 `1.18.29` or later and OpenCode V2 (tested with beta `19242`). The OpenCode config directory must already exist. Herdr installs the server entrypoint at `~/.config/opencode/plugins/herdr-agent-state.js`, the shared TUI plugin at `herdr-tui-session.js`, and a V2 TUI entrypoint at `herdr-opencode/tui.js` in that config directory. + +Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries. + +V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting. The plugin reports lifecycle state and session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session `. Native screen manifest detection remains available when the plugin is not installed. @@ -273,6 +283,20 @@ Herdr uses `~/.qwen` by default, or `QWEN_HOME` when set. The Qwen config direct Herdr resumes stored Qwen Code sessions with `qwen --resume `. +## Letta Code + +Install the Letta Code hook: + +```bash +herdr integration install letta +``` + +The quiet `SessionStart` hook reports the conversation ID for native restore. Letta Code state still comes from Herdr's screen manifest detection. + +Herdr uses `~/.letta`. The config directory must already exist. Install writes `hooks/herdr-agent-session.sh` (`hooks/herdr-agent-session.ps1` on Windows) and adds a Herdr entry to `settings.json`. Uninstall removes only the matching entry and managed script. + +Herdr resumes named conversations with `letta --conversation `. It records the default conversation as `default:` and resumes it with `letta --conversation default --agent `. + ## Cursor Agent CLI Install the Cursor Agent CLI hook: diff --git a/docs/preview/website/src/content/docs/ja/agent-automation.mdx b/docs/preview/website/src/content/docs/ja/agent-automation.mdx index e54febe2..f69d6a3a 100644 --- a/docs/preview/website/src/content/docs/ja/agent-automation.mdx +++ b/docs/preview/website/src/content/docs/ja/agent-automation.mdx @@ -41,7 +41,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') 利用可能なシェルペインとは、対話シェルのプロンプトに戻っており、フォアグラウンドをシェル自身が所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていないペインです。`agent start` の前にプロンプトへ戻してください。 -`--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki`、`muse` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 +`--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki`、`muse` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 成功した `agent start` は、同じターミナルで期待したエージェントを検出し、対話入力の準備ができたと Herdr が判断してから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。起動待機はデフォルトで 30 秒です。`--timeout` は 3000 より大きく 300000 以下のミリ秒で指定します。 @@ -73,7 +73,7 @@ herdr agent rename w1:p2 reviewer ペイン入力は、現在のプロセスに関係なくターミナルを指定します。エージェント入力はライブエージェントを解決し、そのエージェントがペインを制御しなくなっていれば操作を拒否します。 -`agent prompt --wait` は、エージェントがすでに `blocked` の場合、入力も待機も開始せずに `agent_blocked` を返します。それ以外は、プロンプトと遅延した Enter を順序付きの 1 回の送信として書き込んでから待機します。Windows の Codex では、プロンプトのサイズに応じて遅延が長くなります。呼び出し側のタイムアウトには送信時間も含まれます。別の non-working 状態から始まった場合、送信後に最大 5 秒間、`working` または `blocked` を観測するまで待ちます。観測できなければ `agent_prompt_stalled` を返し、呼び出し側のタイムアウトが先に切れた場合は通常の `timeout` エラーを返します。これにより、無関係な `idle`、`done`、またはセッションの変化によって待機が誤って完了することを防ぎます。活動を観測した後、要求された安定状態を待ちます。個々のターンは追跡しません。エージェントがすでに working の場合、進行中ターンの完了が待機を満たすことがあります。単独の `agent wait` は現在のエージェントを監視し、すでに状態が一致していれば即座に返ります。どちらもデフォルトでは `idle`、`done`、`blocked` を待ちます。複数の正確な状態を許可するには、`--until idle --until done` のように `--until` を繰り返します。`unknown` が必要な場合は `--until unknown` を明示してください。`agent prompt` では `--until` に `--wait` が必要です。 +`agent prompt --wait` は、エージェントがすでに `blocked` の場合、入力も待機も開始せずに `agent_blocked` を返します。それ以外は、プロンプトと遅延した Enter を順序付きの 1 回の送信として書き込んでから待機します。Windows の Codex では Enter の前にペースト境界が送られるため、送信はプロンプトのサイズに依存しません。呼び出し側のタイムアウトには送信時間も含まれます。別の non-working 状態から始まった場合、送信後に最大 5 秒間、`working` または `blocked` を観測するまで待ちます。観測できなければ `agent_prompt_stalled` を返し、呼び出し側のタイムアウトが先に切れた場合は通常の `timeout` エラーを返します。これにより、無関係な `idle`、`done`、またはセッションの変化によって待機が誤って完了することを防ぎます。活動を観測した後、要求された安定状態を待ちます。個々のターンは追跡しません。エージェントがすでに working の場合、進行中ターンの完了が待機を満たすことがあります。単独の `agent wait` は現在のエージェントを監視し、すでに状態が一致していれば即座に返ります。どちらもデフォルトでは `idle`、`done`、`blocked` を待ちます。複数の正確な状態を許可するには、`--until idle --until done` のように `--until` を繰り返します。`unknown` が必要な場合は `--until unknown` を明示してください。`agent prompt` では `--until` に `--wait` が必要です。 `idle` と `done` はどちらも入力可能な状態です。CLI/API はサーバーの既読状態を使い、未確認の idle を `done` とします。明示的な `pane focus` / `agent focus` は対象を既読にしますが、読み取りでは変わりません。各 TUI クライアントは表示済みの完了を独立して管理するため、Done バッジが CLI やほかのクライアントと異なる場合があります。`blocked` は承認または質問 UI を Herdr が認識した状態です。`unknown` はエージェントが存在するもののライフサイクルを確実に分類できない状態で、成功完了を意味しません。違いが重要なら正確な `--until` を指定してください。 diff --git a/docs/preview/website/src/content/docs/ja/agents.mdx b/docs/preview/website/src/content/docs/ja/agents.mdx index 40d6940e..3a365221 100644 --- a/docs/preview/website/src/content/docs/ja/agents.mdx +++ b/docs/preview/website/src/content/docs/ja/agents.mdx @@ -21,6 +21,7 @@ Herdr は複数のコーディングエージェントを同時に動かすた | Hermes Agent | スクリーンマニフェスト | セッション | | Qoder CLI | スクリーンマニフェスト | セッション | | Qwen Code | スクリーンマニフェスト | セッション | +| Letta Code | スクリーンマニフェスト | セッション | | Droid | スクリーンマニフェスト | セッション | | OpenCode | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | | Kilo Code CLI | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | diff --git a/docs/preview/website/src/content/docs/ja/cli-reference.mdx b/docs/preview/website/src/content/docs/ja/cli-reference.mdx index abf197eb..58cb3e9f 100644 --- a/docs/preview/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/ja/cli-reference.mdx @@ -64,7 +64,22 @@ herdr machine remove 自動接続と再接続は非対話型です。ホスト鍵、パスワード、鍵のパスフレーズ、MFA、インストール、更新、再起動に承認が必要な場合、隠れたプロンプトを開くのではなく Attention と表示します。`herdr --remote workbox` など、Herdr が表示する単独接続用のコマンドを実行してフォアグラウンドでセットアップを完了し、クライアントを再起動してください。名前付きセッションを選んだプロファイルにだけ `--session ` を追加します。 -ワークスペース、タブ、ペインの ID とエージェント名は、1 つのサーバー内だけで有効です。UI でマシンを選んでも、既存ペイン内のコマンドが継承するセッションやソケットは変わりません。リモート自動化では対象ホスト上の対象セッションに対して CLI を実行し、そこで ID を取得してください。 +ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。UI での選択は CLI の接続先を変えません。保存済み SSH マシンへ API コマンドを送るには、グローバルな**プレフィックス** `--machine ` を指定します。 + +```bash +herdr --machine "Build machine" agent list +herdr --machine pane list +herdr --machine "Build machine" agent prompt w1:p1 "review this change" +herdr --machine "Build machine" worktree create --cwd /srv/project --branch review +``` + +有効なプロファイル ID または一意のラベル(大文字・小文字を区別)を指定します。任意の SSH ホスト名は使えません。保存済みのリモートセッションを使うため、`--session` や `--remote` との併用はエラーです。省略時のローカル動作は変わりません。 + +対応するのは `workspace`、`worktree`、`tab`、`pane`、`notification`、`agent`(`attach` とローカルの `explain --file` 以外)、`api snapshot`、`status server`、API ベースのプラグイン操作(`link`、`unlink`、`enable`、`disable`、`list`、`action`、`log`、`pane`)です。サーバー操作は `stop`、`reload-config`、`agent-manifests`、`reload-agent-manifests` に対応します。ローカルのインストール・設定、プラグインのインストール、セッション管理、対話的な端末接続は転送しません。 + +JSON API を非対話 SSH で転送します。API の内容を SSH のシェルコマンドに埋め込むことはありません。開いた TUI は不要で、サーバーのインストール・起動・再起動も自動では行いません。ローカル CLI とリモート Herdr を更新し、実行中サーバーの API プロトコルとの互換性を保ってください。認証、接続、互換性のエラーで Local にフォールバックしたり、要求を自動再送したりすることはありません。 + +呼び出し元のローカルペイン ID は継承しません。リモート ID を明示してください。`--current` でローカルペインを指定することはできません。リモート worktree パスには絶対パス、`~`、または `~/` で始まるパスを指定します(サーバー側で展開)。プラグインの link パスは絶対パスが必要です。対象は 1 台ずつで、一覧の統合や別の TUI の接続を使った転送は含みません。 ## シェル補完 @@ -309,11 +324,11 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] エージェントターゲットは、一意なライブエージェント名、または現在そのエージェントをホストしているペイン ID です。ターミナル ID とエージェント kind のラベルだけでは指定できません。`agent start` で起動するエージェントには名前が必須で、手動で起動したエージェントは名前なしのままペイン ID で指定します。 -`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki`、`muse` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 +`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki`、`muse` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 成功した start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 -`agent prompt` は現在の bracketed paste モードを尊重し、working 中でもテキストと遅延した Enter を順序付きの 1 回の送信として書き込みます。`--wait` なしの成功は書き込みの完了を示し、ターンの開始を保証しません。Windows の Codex では、プロンプトのサイズに応じて遅延が長くなります。呼び出し側のタイムアウトには送信時間も含まれます。エージェントがすでに `blocked` の場合は、入力を送信せずに `agent_blocked` を返します。`--wait` を使う場合、別の non-working 状態から受け付けたプロンプトでは、送信後の最大 5 秒間に `working` または `blocked` が観測される必要があります。観測できなければ Herdr は `agent_prompt_stalled` を返し、呼び出し側のタイムアウトが先に切れた場合は通常の `timeout` エラーを返します。これにより、無関係な `idle`、`done`、またはセッションの変化によって待機が誤って完了することを防ぎます。活動を観測した後、要求された最初の安定状態を待ちます。個々のターンは追跡しません。すでに working の場合、進行中ターンの完了が待機を満たすことがあります。`--until` は一致状態を絞り込み、`--wait` なしでは拒否されます。単独の `agent wait` は現在の状態が一致すれば即座に返ります。どちらもデフォルトは `idle`、`done`、`blocked` です。 +`agent prompt` は現在の bracketed paste モードを尊重し、working 中でもテキストと遅延した Enter を順序付きの 1 回の送信として書き込みます。`--wait` なしの成功は書き込みの完了を示し、ターンの開始を保証しません。Windows の Codex では Enter の前にペースト境界が送られるため、送信はプロンプトのサイズに依存しません。呼び出し側のタイムアウトには送信時間も含まれます。エージェントがすでに `blocked` の場合は、入力を送信せずに `agent_blocked` を返します。`--wait` を使う場合、別の non-working 状態から受け付けたプロンプトでは、送信後の最大 5 秒間に `working` または `blocked` が観測される必要があります。観測できなければ Herdr は `agent_prompt_stalled` を返し、呼び出し側のタイムアウトが先に切れた場合は通常の `timeout` エラーを返します。これにより、無関係な `idle`、`done`、またはセッションの変化によって待機が誤って完了することを防ぎます。活動を観測した後、要求された最初の安定状態を待ちます。個々のターンは追跡しません。すでに working の場合、進行中ターンの完了が待機を満たすことがあります。`--until` は一致状態を絞り込み、`--wait` なしでは拒否されます。単独の `agent wait` は現在の状態が一致すれば即座に返ります。どちらもデフォルトは `idle`、`done`、`blocked` です。 `idle` と `done` はどちらも入力可能な状態です。CLI/API はサーバーの既読状態を使い、未確認の idle を `done` とします。明示的な `pane focus` / `agent focus` は対象を既読にしますが、読み取りでは変わりません。各 TUI クライアントは表示済みの完了を独立して管理するため、Done バッジが CLI やほかのクライアントと異なる場合があります。`blocked` は承認または質問 UI を Herdr が認識した状態です。`unknown` はエージェントが存在するものの確実に分類できない状態で、作業の成功を意味しません。 @@ -367,6 +382,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration uninstall pi herdr integration uninstall omp @@ -382,6 +398,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration status [--outdated-only] ``` diff --git a/docs/preview/website/src/content/docs/ja/configuration.mdx b/docs/preview/website/src/content/docs/ja/configuration.mdx index e4c722ff..7867e8c4 100644 --- a/docs/preview/website/src/content/docs/ja/configuration.mdx +++ b/docs/preview/website/src/content/docs/ja/configuration.mdx @@ -411,7 +411,7 @@ rows = [ ] ``` -最初に一致したルールだけを適用します。指定されたスタイルはその出現箇所のデフォルトを上書きし、省略したフィールドは継承します。一致しなければデフォルトを保ちます。判定には表示幅で切り詰める前の値全体を使い、テキスト、区切り、表示の有無は変更しません。スタイルを指定しないルールに一致すると、そこで判定を終了してデフォルトを保ちます。 +最初に一致したルールだけを適用します。指定されたスタイルはその出現箇所のデフォルトを上書きし、省略したフィールドは継承します。一致しなければデフォルトを保ちます。判定には表示幅で切り詰める前の値全体を使います。ルールに `hide = true` を指定すると、一致したトークンとその区切りを非表示にします。残るトークンがない行も非表示になります。たとえば `{ token = "machine", fg = "#61afef", rules = [{ equals = "Local", hide = true }] }` は、ラベルが正確に `Local` の場合にマシントークンを非表示にします。接続の種類ではなくラベルを比較します。`hide = false` または省略時は表示を維持します。上書きを指定しないルールでも、一致するとそこで判定を終了してデフォルトを保ちます。 `equals`、`contains`、`starts_with` は文字列を受け取り、大文字と小文字を区別します。`ignore_case = true` は ASCII だけを区別しなくなり、非 ASCII 文字は引き続き区別します。空文字列の `equals` は空の値だけに一致し、空の `contains` と `starts_with` は存在するすべての値に一致します。 diff --git a/docs/preview/website/src/content/docs/ja/connecting-machines.mdx b/docs/preview/website/src/content/docs/ja/connecting-machines.mdx index 1a899aec..014e670f 100644 --- a/docs/preview/website/src/content/docs/ja/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/ja/connecting-machines.mdx @@ -87,7 +87,7 @@ UI はデフォルトでクライアントのローカルテーマ、サイド 複数のマシンがある場合、デフォルトの Agent 行に `machine` トークンを表示します。既存のカスタム行は維持されるため、ラベルが必要なら `machine` を追加してください。[サイドバーの行レイアウト](/ja/docs/configuration/)ではマシンラベルの条件付き色も設定できます。 -ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。2 台に同じ `w1:p1` や `reviewer` が存在する場合があります。UI でマシンを選んでも、既存ペイン内の CLI は継承したセッションとソケットを使い続けます。リモート自動化では、対象ホストの対象セッションでコマンドを実行し、そこで ID を読み取ってください。 +ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。2 台に同じ `w1:p1` や `reviewer` が存在する場合があります。UI でマシンを選んでも、既存ペイン内の CLI は継承したセッションとソケットを使い続けます。リモート自動化では `herdr --machine agent list` で ID を取得し、操作時にも同じプレフィックスを指定します。CLI は保存済みプロファイルの SSH 接続先とセッションを直接使うため、TUI を開く必要はありません。`--machine` を省略した場合の動作は変わりません。対応コマンド、更新要件、パスの扱いは [CLI リファレンス](/ja/docs/cli-reference/#保存済み-ssh-マシン) を参照してください。 ## 更新と保存データ diff --git a/docs/preview/website/src/content/docs/ja/integrations.mdx b/docs/preview/website/src/content/docs/ja/integrations.mdx index 44d1b3e7..75cebbe4 100644 --- a/docs/preview/website/src/content/docs/ja/integrations.mdx +++ b/docs/preview/website/src/content/docs/ja/integrations.mdx @@ -1,6 +1,6 @@ --- title: インテグレーション -description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 +description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 --- Herdr は対応エージェントを自動的に検出します。公式インテグレーションは、復元のためのネイティブセッション識別、ライフサイクル状態の報告、またはその両方を追加できます。 @@ -26,6 +26,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install antigravity-cli herdr integration install grok @@ -48,11 +49,14 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall antigravity-cli herdr integration uninstall grok ``` +Herdr は Linux/macOS の統合設定と全プラットフォームの新規ファイルをアトミックに保存し、Windows の既存ファイルは権限を維持するため、バックアップ後に同じファイルを更新します。Windows でバックアップが通知された場合は、再試行前にエージェントを終了して設定を確認し、未完了の `.herdr-backup.pending` は復旧元に使わず設定が正しいことを確認してから削除するか、必要なら完成済みの `.herdr-backup` の内容を既存ファイルに書き戻し(ファイルがない場合は先に意図した権限を設定)、設定が正しいことを確認してからバックアップを削除してください。 + ## Herdr がインテグレーションをどう使うか Herdr はインテグレーションを 2 つの異なる方法で使います: @@ -60,7 +64,7 @@ Herdr はインテグレーションを 2 つの異なる方法で使います: | インテグレーションの種類 | エージェント | 効果 | | --- | --- | --- | | ライフサイクル権威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | インストールされ、そのペインについて能動的に報告している間は、フックまたはプラグインのイベントが `idle`、`working`、`blocked` を決定します。同じライフサイクル権威に対して、Herdr はスクリーンマニフェストのフォールバックを併用しません。 | -| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | +| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。 @@ -91,9 +95,9 @@ Herdr の外では何もしないように、`HERDR_ENV=1` で必要な変数が [Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際の実装例です。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時に権威を解放します。 -一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 +一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 -エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `1`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 +エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Letta Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `2`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 ## Pi @@ -139,6 +143,8 @@ herdr integration install claude このフックは、セッション開始時に Claude Code のセッション識別をローカルの Herdr ソケットに報告します。Claude Code の状態は Herdr のスクリーンマニフェスト検出から得られます。 +Claude 統合 v10 は、文書化された `SessionStart` の source 値 `startup`、`resume`、`clear`、`compact`、`fork` のみに一致します。これにより、ほかの Claude 互換フックを無効にせずに、Grok の `new` と `load` イベントがインポートされた Herdr の Claude フックを起動するのを防ぎます。アップグレード後に Claude 統合を再インストールして、従来のワイルドカードマッチャーを移行してください。Claude に新しい source 値が追加された場合は、統合の更新が必要です。 + Herdr はデフォルトで `~/.claude` を使い、`CLAUDE_CONFIG_DIR` が設定されていればそちらを使います。Claude の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh` を書き込み、`settings.json` に Herdr のフックエントリを追加します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 ## Codex @@ -217,7 +223,11 @@ OpenCode プラグインをインストールします: herdr integration install opencode ``` -Herdr はプラグインを `~/.config/opencode/plugins/herdr-agent-state.js` に書き込みます。OpenCode の設定ディレクトリはあらかじめ存在している必要があります。アンインストールはそのプラグインファイルだけを削除します。 +この連携は OpenCode V1 `1.18.29` 以降と OpenCode V2(beta `19242` で検証済み)に対応します。OpenCode の設定ディレクトリはあらかじめ存在している必要があります。Herdr はサーバー側のエントリポイントを `~/.config/opencode/plugins/herdr-agent-state.js` に、共通 TUI プラグインを同じ設定ディレクトリの `herdr-tui-session.js` に、V2 TUI エントリポイントを `herdr-opencode/tui.js` にインストールします。 + +インストール時、V1 TUI プラグインを `tui.jsonc` に、V2 TUI プラグインを `cli.json` に登録し、他の設定やプラグインは保持します。OpenCode に移行すべき V1 TUI 設定(`tui.json` または `kv.json`)が残っている場合、Herdr は初回起動時の移行を妨げないよう登録を見送ります。その場合は一度 `opencode2` を起動してから連携を再インストールしてください。それ以外の場合は Herdr が `cli.json` を作成してプラグインを登録します。インストール後は OpenCode TUI を再起動してください。アンインストールは管理対象のプラグインファイルと設定項目を削除します。 + +V2 のライフサイクル報告はペイン内の TUI で実行され、複数のペインが同じ OpenCode サーバーを共有する場合も、選択されたルートセッションにイベントを対応付けます。完了時または中断時に working 状態を解除し、未処理の権限要求やフォーム、実行失敗は blocked 状態を維持します。V2 Mini とヘッドレスクライアントは TUI プラグインを実行しないため、このライフサイクル報告は利用できません。 このプラグインは、OpenCode が Herdr のペイン内で動いている間、ライフサイクル状態とセッション識別を報告します。OpenCode がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `opencode --session ` でペインを resume できます。プラグインがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。 @@ -275,6 +285,20 @@ Herdr はデフォルトで `~/.qwen` を使い、`QWEN_HOME` が設定されて Herdr は保存された Qwen Code セッションを `qwen --resume ` で resume します。 +## Letta Code + +Letta Code フックをインストールします: + +```bash +herdr integration install letta +``` + +静かな `SessionStart` フックは、ネイティブ復元用の会話 ID を報告します。Letta Code の状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr は `~/.letta` を使います。設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-session.sh`(Windows では `hooks/herdr-agent-session.ps1`)を書き込み、`settings.json` に Herdr エントリを追加します。アンインストールは一致するエントリと管理対象スクリプトだけを削除します。 + +Herdr は名前付き会話を `letta --conversation ` で resume します。デフォルト会話は `default:` として記録し、`letta --conversation default --agent ` で resume します。 + ## Cursor Agent CLI Cursor Agent CLI フックをインストールします: diff --git a/docs/preview/website/src/content/docs/ja/keyboard.mdx b/docs/preview/website/src/content/docs/ja/keyboard.mdx index 24b554c8..97ce6b5c 100644 --- a/docs/preview/website/src/content/docs/ja/keyboard.mdx +++ b/docs/preview/website/src/content/docs/ja/keyboard.mdx @@ -66,6 +66,8 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚 `prefix+[` を押すと、フォーカス中のペインでコピーモードに入ります。`h/j/k/l`、tmux 形式の `w/b/e`、`{`/`}` で移動します。`/` または `?` で前方または後方のリテラル検索を開始し、`n` または `N` で同じ方向または逆方向に繰り返します。クエリに大文字が含まれる場合だけ大文字と小文字を区別します。`v` または Space で選択を開始し、`y` または Enter でコピーし、`q` または Esc でコピーせずに抜けます。Esc は終了する前に、アクティブな選択または検索を消します。コピーモードはペインのプロセスを停止しません。最下部では出力を追従し、履歴へ移動するとその位置を保ちます。マウスのドラッグ選択なら、コピーモードに入らずにそのままコピーできます。 +マウスのドラッグ選択と、`v`、Space、`V` で開始した選択は、出力や再描画が続いても維持されます。選択範囲の一部が表示領域の外にある場合も同様です。コピーするのは固定されたスナップショットではなく、その範囲に現在あるテキストです。ペインのサイズ変更や通常画面と代替画面の切り替えでは、引き続き選択が解除されます。検索結果とダブルクリックによる単語選択では、内容変更のチェックが維持されます。 + ## 何でも変更できる プレフィックス自体を含め、すべてのバインドは設定可能です: diff --git a/docs/preview/website/src/content/docs/ja/persistence-remote.mdx b/docs/preview/website/src/content/docs/ja/persistence-remote.mdx index 77eefb51..acd75b86 100644 --- a/docs/preview/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/ja/persistence-remote.mdx @@ -27,6 +27,8 @@ herdr session delete side-project 名前付きセッションは独自のペイン、タブ、ワークスペース、ソケット、ランタイム状態を持ちます。グローバル設定ファイルは共有されます。 +セッションを削除するときは、大文字と小文字も含めて `herdr session list` に表示される名前を正確に指定してください。大文字と小文字を区別しないファイルシステムでは、`herdr session delete foo` は `Foo` という名前のセッションの削除を拒否します。 + スクリプトでは `--json` を使ってください: ```bash diff --git a/docs/preview/website/src/content/docs/ja/session-state.mdx b/docs/preview/website/src/content/docs/ja/session-state.mdx index eaf78da4..b2d182b0 100644 --- a/docs/preview/website/src/content/docs/ja/session-state.mdx +++ b/docs/preview/website/src/content/docs/ja/session-state.mdx @@ -34,6 +34,10 @@ Herdr サーバーが停止して再起動すると、元のペインのプロ スナップショット復元は、動作中のシェル、サーバー、テスト、その他任意のプロセスを保存しません。より強力な復元経路が使えないペインは、保存されたディレクトリで新しいシェルとして戻ってきます。 +`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。正常に読み込めた場合は復旧用コピーを作成しません。 + +復旧用コピーは最新の 3 個を保持し、新しいコピーの安全な保存が完了してから古いコピーを削除します。コピーに失敗すると、自動保存時も終了時も元のファイルを変更せず、失敗をログに記録し、次の保存要求時に再試行します。コピーが自動的に復元されることはありません。復旧するには、対象のサーバーを停止し、復旧用ファイルをそのサーバーの `session.json` にコピーしてから再起動します。復旧用コピーにペイン画面履歴は含まれません。 + ## ペイン画面履歴のリプレイ ペイン画面履歴は、サーバーの完全な再起動後に直近のターミナル内容を復元します。復元されるのは Herdr が表示できるものであって、元のプロセスではありません。 @@ -77,6 +81,7 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation `、または `default:` の場合は `letta --conversation default --agent ` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/preview/website/src/content/docs/ja/troubleshooting.mdx b/docs/preview/website/src/content/docs/ja/troubleshooting.mdx index a170b0b1..345d8a99 100644 --- a/docs/preview/website/src/content/docs/ja/troubleshooting.mdx +++ b/docs/preview/website/src/content/docs/ja/troubleshooting.mdx @@ -78,7 +78,7 @@ Herdr のペインからサーバーの起動コンテキストを確認しま launchctl managername ``` -`Background` と表示された場合は、サーバーを停止し、通常の GUI ターミナルから Herdr を再起動してください: +自動起動された macOS サーバーでは `Background` は正常で、この表示だけでは Keychain の問題を意味しません。Keychain にアクセスできず、サーバーを SSH やバックグラウンドジョブから起動していた場合は、停止して通常の GUI ターミナルから Herdr を再起動してください: ```bash herdr server stop @@ -87,6 +87,14 @@ herdr サーバーを停止するとペインのプロセスも終了します。Herdr のペインは長時間動作するサーバーの macOS 起動コンテキストを継承するため、SSH やバックグラウンドジョブから起動されたサーバーは対話型 Keychain サービスにアクセスできないことがあります。詳細は [Herdr issue #966](https://github.com/herdrdev/herdr/issues/966) を参照してください。 +## macOS のログアウト後に SSH や DNS が失敗する + +自動起動された macOS サーバーはユーザー単位のサービスコンテキストを使用するため、ログアウトしてもペインと macOS サービスの接続が無効になりません。明示的な `herdr server` 起動では、呼び出し元のコンテキストを維持します。 + +古いサーバーはログアウト後も動作し続ける一方、ユーザー情報の検索や DNS が使えなくなることがあります。症状には `No user exists for uid 501`、`Could not get manager name.`、`curl: (6) Could not resolve host` などがあります。再ログインだけでは修復されません。対象セッションを停止し、新しいログインの GUI ターミナルから Herdr を起動してください。実行中のペインプロセスは終了しますが、保存されたセッション配置は復元されます。ライブハンドオフは元のサーバーのサービスコンテキストと既存のペインプロセスを維持します。そのため、古いサーバーにこの修正を適用するにはセッションの完全な再起動が必要です。ハンドオフでは継承したコンテキストを修復できません。 + +新しく起動したサーバーでも問題が起きる場合は、`herdr-server.log` の `could not select persistent user service context` を確認してください。必要な macOS API が利用できない場合、Herdr は継承したコンテキストを維持します。[Herdr issue #4100](https://github.com/herdrdev/herdr/issues/4100) を参照してください。 + ## `herdr` コマンドが見つからない ターミナルを再起動して環境を読み込み直し、Herdr のインストール先が `PATH` に含まれていることを確認してください。パッケージマネージャー経由のインストールは、そのパッケージマネージャーから更新して公開する必要があります。[インストール](/ja/docs/install/#verify)を参照してください。 diff --git a/docs/preview/website/src/content/docs/keyboard.mdx b/docs/preview/website/src/content/docs/keyboard.mdx index 84f78013..d20b34b3 100644 --- a/docs/preview/website/src/content/docs/keyboard.mdx +++ b/docs/preview/website/src/content/docs/keyboard.mdx @@ -66,6 +66,8 @@ The full keymap and the binding syntax live in the [keybinding reference](/docs/ Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, tmux-style `w/b/e` and big-word `W/B/E`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move. Press `/` or `?` for forward or backward literal search, then `n` or `N` to repeat in the same or opposite direction. Search is case-insensitive unless the query contains an uppercase letter. Use `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. Esc clears an active selection or search before exiting. Copy mode does not pause the pane process: output remains live, follows at the bottom, and stays pinned when you navigate into history. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. +Mouse drag selections and selections started with `v`, Space, or `V` remain active during output and redraws, including when part of the selected range is outside the viewport. Copy reads the current text in that range, not a frozen snapshot. Resizing the pane or switching between its normal and alternate screens still clears the selection. Search results and double-click word selections retain their content-change checks. + ## Change anything Every binding is configurable, including the prefix itself: diff --git a/docs/preview/website/src/content/docs/persistence-remote.mdx b/docs/preview/website/src/content/docs/persistence-remote.mdx index c45b4081..4f42cef5 100644 --- a/docs/preview/website/src/content/docs/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/persistence-remote.mdx @@ -27,6 +27,8 @@ herdr session delete side-project A named session has its own panes, tabs, workspaces, sockets, and runtime state. It still shares the same global config file. +When deleting a session, use the exact name shown by `herdr session list`, including letter case. On case-insensitive filesystems, `herdr session delete foo` refuses to delete a session named `Foo`. + Use `--json` for scripts: ```bash @@ -67,7 +69,7 @@ Then attach with: herdr --remote workbox ``` -Remote attach supports Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. Local and remote versions do not need to match once both support the stable endpoint generation. If no compatible binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. Windows is not supported as the remote host. +Remote attach and saved SSH machines support Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64, or Windows hosts on x86_64. Local and remote versions do not need to match once both support the stable endpoint generation. On Linux and macOS hosts, Herdr prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. On Windows hosts, Herdr checks `PATH` and the active managed release. If no compatible binary exists, interactive runs prompt to install or update one; non-interactive runs fail instead of modifying the host. Linux and macOS installs use `~/.local/bin/herdr`; Windows installs use the complete Windows package and its app-local ConPTY runtime. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Linux and macOS clients also use a private per-attach control socket for connection reuse; Windows OpenSSH does not. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket. @@ -88,9 +90,9 @@ herdr --remote workbox --handoff If you SSH into the server first and run `herdr` there, Herdr runs entirely on the server and cannot access your local desktop clipboard beyond normal terminal text paste. -When your local and remote platforms match, Herdr can copy the current local binary for direct installs. For Homebrew, mise, and Nix installs, or when the platforms differ, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`. +For Linux and macOS hosts, Herdr can copy the current local binary when the platforms match. For Homebrew, mise, and Nix installs, when the platforms differ, or for Windows hosts, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`. -For local builds or custom binaries, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. +For local builds or custom packages, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. Use a bare Herdr executable for Linux or macOS targets and a complete `herdr-windows-x86_64.zip` package for Windows targets. ```bash HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox diff --git a/docs/preview/website/src/content/docs/quick-start.mdx b/docs/preview/website/src/content/docs/quick-start.mdx index 5ac2d56d..e5c45e4a 100644 --- a/docs/preview/website/src/content/docs/quick-start.mdx +++ b/docs/preview/website/src/content/docs/quick-start.mdx @@ -17,9 +17,9 @@ When a session has no workspaces, Herdr opens one automatically. A workspace is ## Use the mouse -Herdr is mouse-native, so start by clicking panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard; double-click a token to copy it directly. Copying does not require Ctrl+C. +Herdr is mouse-native, so start by clicking panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard. Double-click a token to select it, or hold the second press and drag to extend the selection by whole words. Both gestures copy when you release the mouse. Copying does not require Ctrl+C. -Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and visible `http://` or `https://` URLs. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`. +Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and `http://` or `https://` URLs, including wrapped links whose beginning or end is outside the visible area. Hold Ctrl and move the pointer over a link to underline its visible segments. Hover highlighting clears when you move away, the pane content changes, or Herdr receives a Ctrl-release event; some terminals only report modifier changes with mouse movement. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`. If you configure `ui.right_click_passthrough_modifier`, that modifier plus right-click sends right-click, hold, and drag gestures to mouse-reporting pane apps. To make normal right-click go to one pane app, choose **Send right-clicks to pane** from that pane's menu or run `herdr pane input --current --right-click pane` inside it. Right-click the pane frame to reopen Herdr's menu. diff --git a/docs/preview/website/src/content/docs/session-state.mdx b/docs/preview/website/src/content/docs/session-state.mdx index ed147193..38712689 100644 --- a/docs/preview/website/src/content/docs/session-state.mdx +++ b/docs/preview/website/src/content/docs/session-state.mdx @@ -32,6 +32,10 @@ If the Herdr server stops and starts again, the original pane processes are gone Snapshot restore does not preserve running shells, servers, tests, or arbitrary processes. Panes that cannot use a stronger restore path come back as new shells in their saved directories. +If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. Healthy restores do not create recovery copies. + +Herdr retains the three most recent recovery copies, pruning older copies only after a new copy is safely written. If preservation fails, autosave and shutdown leave the original untouched and log the failure; preservation is retried on the next save request. Copies are never restored automatically. To recover one, stop the affected server, copy the recovery file over its `session.json`, then restart. Recovery copies do not include pane screen history. + ## Pane screen history replay Pane screen history restores recent terminal contents after a full server restart without restoring the old process. @@ -70,13 +74,14 @@ Native session restore requires these Herdr integration versions or newer: | Claude Code | `6` | `claude --resume ` | | Codex | `5` | `codex resume ` | | Cursor Agent CLI | `1` | `cursor-agent --resume ` | -| Grok CLI | `1` | `grok --resume ` | +| Grok CLI | `2` | `grok --resume ` | | GitHub Copilot CLI | `2` | `copilot --resume=` | | Devin CLI | `2` | `devin --resume ` | | Droid | `2` | `droid --resume ` | | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation ` or `letta --conversation default --agent ` for `default:` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/preview/website/src/content/docs/socket-api.mdx b/docs/preview/website/src/content/docs/socket-api.mdx index 13a71abb..5bd933dd 100644 --- a/docs/preview/website/src/content/docs/socket-api.mdx +++ b/docs/preview/website/src/content/docs/socket-api.mdx @@ -461,9 +461,13 @@ Built-in filter fields are `status`, `workspace_id`, `tab_id`, `pane_id`, `agent`, `seen`, and `state_change_seq`. Use `{"token":"name"}` as a field to filter plugin-reported pane metadata. Values are strings, booleans, unsigned numbers, or a context object. Context values are `current_workspace_id` and -`current_tab_id`, and may only be compared to the matching ID field. Effective -status values are `idle`, `working`, `blocked`, `done`, and `unknown`; `done` -means idle and not yet seen. +`current_tab_id`, and may only be compared to the matching ID field. In a +saved-machine client, the selected server's view applies to the combined agent +list. Current workspace and tab context includes that selected machine, so an +identical ID on another machine does not match; independent filter branches, +such as `status` being `blocked`, still match agents on any connected machine. +Effective status values are `idle`, `working`, `blocked`, `done`, and `unknown`; +`done` means idle and not yet seen. Sort fields are `workspace_order`, `tab_order`, `pane_order`, `attention`, `status`, `agent`, `seen`, `state_change_seq`, or `{"token":"name"}`. Sorts are @@ -821,6 +825,9 @@ that workspace. Pane event subscriptions include `pane.created`, `pane.updated`, `pane.closed`, `pane.focused`, `pane.moved`, `pane.exited`, `pane.agent_detected`, `pane.output_matched`, `pane.agent_status_changed`, and `pane.scroll_changed`. +`pane.focused` also reports manual pane selection changes from any client attached +to this server. It does not move other clients' views, and its payload does not +identify the client. Selecting an already-selected pane does not emit it again. Terminal-title changes can emit `pane.updated`, but spinner-only raw-title changes do not emit it when `terminal_title_stripped` is unchanged. `pane.scroll_changed` is scoped to one `pane_id` and emits `pane_id`, `workspace_id`, and the current `scroll` metrics whenever Herdr observes a diff --git a/docs/preview/website/src/content/docs/troubleshooting.mdx b/docs/preview/website/src/content/docs/troubleshooting.mdx index 69e65110..e7225893 100644 --- a/docs/preview/website/src/content/docs/troubleshooting.mdx +++ b/docs/preview/website/src/content/docs/troubleshooting.mdx @@ -78,7 +78,7 @@ Check the server launch context from a Herdr pane: launchctl managername ``` -If it prints `Background`, stop the server and start Herdr again from a normal GUI terminal: +`Background` is normal for automatically started macOS servers and does not by itself indicate a Keychain problem. If Keychain access fails and the server was started through SSH or a background job, stop it and start Herdr again from a normal GUI terminal: ```bash herdr server stop @@ -87,6 +87,14 @@ herdr Stopping the server exits its pane processes. Herdr panes inherit the long-lived server's macOS launch context, so a server started through SSH or a background job may not have access to interactive Keychain services. See [Herdr issue #966](https://github.com/herdrdev/herdr/issues/966) for details. +## SSH or DNS fails after macOS logout + +Automatically started macOS servers use a per-user service context so logging out does not leave their panes with a dead connection to macOS services. Explicit `herdr server` launches retain their caller's context. + +An older server can survive logout while losing user lookup and DNS. Symptoms include `No user exists for uid 501`, `Could not get manager name.`, and `curl: (6) Could not resolve host`. Logging back in does not repair that server. Stop the affected session and start Herdr from a GUI terminal in the new login. This ends live pane processes but restores the saved session layout. Live handoff preserves the source server's service context and existing pane processes. Older servers therefore need a full session restart to adopt this fix; handoff cannot repair their inherited context. + +If a newly started server still has this problem, check `herdr-server.log` for `could not select persistent user service context`. Herdr keeps the inherited context if the required macOS APIs are unavailable. See [Herdr issue #4100](https://github.com/herdrdev/herdr/issues/4100). + ## The `herdr` command is not found Restart the terminal so it reloads its environment, then confirm the Herdr install directory is on `PATH`. For package-manager installs, use that package manager to update Herdr and expose it on `PATH`. See [Install Herdr](/docs/install/#verify). diff --git a/docs/preview/website/src/content/docs/windows-beta.mdx b/docs/preview/website/src/content/docs/windows-beta.mdx index 255727a2..888080c8 100644 --- a/docs/preview/website/src/content/docs/windows-beta.mdx +++ b/docs/preview/website/src/content/docs/windows-beta.mdx @@ -32,7 +32,7 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m | Local persistent sessions | supported | | Native panes through ConPTY | supported | | Windows Terminal / PowerShell app attach | supported | -| `herdr --remote` to Linux/macOS hosts | supported | +| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; interactive attach and saved-machine setup can install or update Windows packages after confirmation; background reconnect only discovers installed packages | | Remote clipboard images and image-file drops | supported | | `cmd.exe` panes | supported | | Native keyboard and mouse input | supported | @@ -80,7 +80,7 @@ switch_ascii_input_source_in_prefix = true Other Windows IMEs are not supported by this option yet. See [Configuration](/docs/configuration/#prefix-input-source-switching). -Some Windows agents can receive `ctrl+v` and read clipboard images directly. Herdr's own clipboard-image reader is not wired into local native Windows panes, so agent-native image paste remains dependent on the terminal and agent. Agent image-paste shortcuts such as `alt+v` do not add a Herdr-managed local clipboard bridge. Remote clipboard image bridging is supported separately through `herdr --remote` to Linux and macOS hosts. +Some Windows agents can receive `ctrl+v` and read clipboard images directly. Herdr's own clipboard-image reader is not wired into local native Windows panes, so agent-native image paste remains dependent on the terminal and agent. Agent image-paste shortcuts such as `alt+v` do not add a Herdr-managed local clipboard bridge. Remote clipboard image bridging is supported separately through `herdr --remote` to Linux, macOS, and Windows hosts. Kitty graphics is enabled by default and depends on the outer terminal. Herdr emits Kitty graphics protocol output on Windows as it does on other platforms. This path has been exercised with Windows WezTerm hosting Herdr through WSL, but native Windows terminal and ConPTY combinations are not all verified. Windows Terminal does not expose the Kitty graphics path Herdr uses. Set `[terminal].kitty_graphics = false` if the outer terminal mishandles graphics output. @@ -118,7 +118,6 @@ For text paste, use `ctrl+shift+v` in Windows Terminal. Multiline text paste is | Capability | Status | | --- | --- | | Direct terminal attach (`herdr terminal attach`) | unsupported | -| Windows as a `herdr --remote` target host | unsupported | | Live server handoff | unsupported | | Unix file-descriptor handoff | unsupported | | Unix foreground process groups | unsupported | @@ -131,7 +130,7 @@ From Windows Terminal, use the same remote command as Linux and macOS: herdr --remote workbox ``` -The target host must run Linux or macOS. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. +The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. Interactive direct attach and saved-machine setup prompt before installing or updating the complete package when needed. Background saved reconnect only discovers installed packages and cannot prompt for installation or updates. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release. Live handoff is Unix-only. diff --git a/docs/preview/website/src/content/docs/zh-cn/agent-automation.mdx b/docs/preview/website/src/content/docs/zh-cn/agent-automation.mdx index b484e530..8ad27952 100644 --- a/docs/preview/website/src/content/docs/zh-cn/agent-automation.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/agent-automation.mdx @@ -41,7 +41,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') 可用的 shell 窗格必须停在交互式 shell 提示符,由 shell 自身占用前台,没有正在前台运行的命令、编辑器或智能体。调用 `agent start` 前先让窗格回到提示符。 -`--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` 和 `muse`。`--` 后的参数会原样传给该可执行文件。 +`--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` 和 `muse`。`--` 后的参数会原样传给该可执行文件。 成功的 `agent start` 只有在 Herdr 于同一终端检测到预期智能体,并确认它可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认等待启动 30 秒;`--timeout` 必须大于 3000 且不超过 300000 毫秒。 @@ -73,7 +73,7 @@ herdr agent rename w1:p2 reviewer 窗格输入直接指定终端,不关心当前进程。智能体输入会解析实时智能体;如果该智能体已不再控制此窗格,操作会被拒绝。 -如果智能体已经是 `blocked`,`agent prompt --wait` 会返回 `agent_blocked`,不会发送输入或开始等待。否则,它会将提示文本和延迟的 Enter 作为一次有序提交写入,然后等待状态。Windows 上 Codex 的提交延迟随提示大小增加;调用方的超时包含提交时间。如果提示从其他非 working 状态开始,提交后最多等待五秒来观察 `working` 或 `blocked`。如果没有观察到,Herdr 返回 `agent_prompt_stalled`;如果调用方的超时先到期,则返回普通的 `timeout` 错误。这可防止无关的 `idle`、`done` 或会话变化错误地完成等待。观察到活动后,它会等待请求的稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。独立的 `agent wait` 会观察当前智能体;如果状态已经匹配,就会立即返回。两者默认匹配 `idle`、`done` 或 `blocked`。可以重复使用 `--until` 接受多个精确状态,例如 `--until idle --until done`;需要 `unknown` 时请明确使用 `--until unknown`。在 `agent prompt` 中,`--until` 必须与 `--wait` 一起使用。 +如果智能体已经是 `blocked`,`agent prompt --wait` 会返回 `agent_blocked`,不会发送输入或开始等待。否则,它会将提示文本和延迟的 Enter 作为一次有序提交写入,然后等待状态。在 Windows 上,Codex 在 Enter 前会收到粘贴边界,因此提交不依赖提示大小;调用方的超时包含提交时间。如果提示从其他非 working 状态开始,提交后最多等待五秒来观察 `working` 或 `blocked`。如果没有观察到,Herdr 返回 `agent_prompt_stalled`;如果调用方的超时先到期,则返回普通的 `timeout` 错误。这可防止无关的 `idle`、`done` 或会话变化错误地完成等待。观察到活动后,它会等待请求的稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。独立的 `agent wait` 会观察当前智能体;如果状态已经匹配,就会立即返回。两者默认匹配 `idle`、`done` 或 `blocked`。可以重复使用 `--until` 接受多个精确状态,例如 `--until idle --until done`;需要 `unknown` 时请明确使用 `--until unknown`。在 `agent prompt` 中,`--until` 必须与 `--wait` 一起使用。 `idle` 和 `done` 都表示可以接受输入。CLI/API 使用服务器的已查看状态:`done` 表示尚未标记为已查看的 idle;显式 `pane focus` / `agent focus` 会标记目标,读取不会。每个 TUI 客户端独立记录它已显示的完成状态,因此其 Done 标记可能与 CLI 或其他客户端不同。`blocked` 表示 Herdr 识别到了审批或提问界面。`unknown` 表示智能体存在,但 Herdr 无法可靠判断其生命周期;它不代表工作成功完成。区别重要时,请指定精确的 `--until` 状态。 diff --git a/docs/preview/website/src/content/docs/zh-cn/agents.mdx b/docs/preview/website/src/content/docs/zh-cn/agents.mdx index 27b3a8be..bc749add 100644 --- a/docs/preview/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/agents.mdx @@ -21,6 +21,7 @@ Herdr 为同时运行多个编程智能体而生。每个智能体都待在一 | Hermes Agent | 屏幕清单 | 会话 | | Qoder CLI | 屏幕清单 | 会话 | | Qwen Code | 屏幕清单 | 会话 | +| Letta Code | 屏幕清单 | 会话 | | Droid | 屏幕清单 | 会话 | | OpenCode | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | | Kilo Code CLI | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | diff --git a/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx index 5264b419..8795c45e 100644 --- a/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx @@ -64,7 +64,22 @@ herdr machine remove 自动连接和重连均为非交互式。如果主机密钥、密码、密钥口令、MFA、安装、更新或重启需要批准,机器会显示 Attention,而不会打开隐藏的提示。请运行 Herdr 显示的独立连接命令,例如 `herdr --remote workbox`,在前台完成设置后重启客户端。仅当配置指向命名会话时,才加上 `--session `。 -工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。在 UI 中选择机器不会改变已有窗格中命令继承的会话或 socket。远程自动化应在目标主机上针对目标会话运行 CLI,并从那里获取 ID。 +工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。UI 中的选择不会改变 CLI 的目标。使用全局**前缀** `--machine ` 将 API 命令发送到已保存的 SSH 机器: + +```bash +herdr --machine "Build machine" agent list +herdr --machine pane list +herdr --machine "Build machine" agent prompt w1:p1 "review this change" +herdr --machine "Build machine" worktree create --cwd /srv/project --branch review +``` + +选择器必须是已启用的配置 ID 或唯一标签(区分大小写),不能是任意 SSH 主机名。命令使用保存的远程会话,不能与 `--session` 或 `--remote` 混用。省略前缀时,本地行为保持不变。 + +支持 `workspace`、`worktree`、`tab`、`pane`、`notification`、`agent`(不含 `attach` 和本地 `explain --file`)、`api snapshot`、`status server`,以及基于 API 的插件操作(`link`、`unlink`、`enable`、`disable`、`list`、`action`、`log`、`pane`)。服务器操作支持 `stop`、`reload-config`、`agent-manifests`、`reload-agent-manifests`。本地安装和配置、插件安装、会话管理及交互式终端连接不会被转发。 + +请求和响应通过非交互式 SSH 传输 JSON API,API 内容不会被拼接到 SSH shell 命令中。不需要打开 TUI,也不会自动安装、启动或重启服务器。请更新本地 CLI 和远程 Herdr,并确保运行中的服务器 API 协议兼容。认证、连接或版本错误不会回退到 Local,连接失败后也不会自动重试请求。 + +远程命令不会继承调用方的本地窗格 ID,请显式指定远程 ID。`--current` 不能引用本地窗格。远程 worktree 路径必须为绝对路径、`~` 或以 `~/` 开头(由服务器展开);插件 link 路径必须为绝对路径。每次只操作一台机器,不包含合并列表或通过其他 TUI 连接转发的功能。 ## Shell 补全 @@ -309,11 +324,11 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] 智能体目标只能是唯一的实时智能体名称,或当前承载该智能体的窗格 ID。终端 ID 和单独的智能体 kind 标签不能作为目标。通过 `agent start` 启动的智能体必须有名称;手动启动的智能体保持未命名,通过窗格 ID 寻址。 -`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` 和 `muse`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 +`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` 和 `muse`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 成功的 start 只有在预期智能体占用同一终端并可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 -`agent prompt` 遵循当前的 bracketed paste 模式,即使智能体处于 working,也会将文本和延迟的 Enter 作为一次有序提交写入。不带 `--wait` 时,成功只确认写入完成,不代表轮次已经开始。Windows 上 Codex 的延迟随提示大小增加;调用方的超时包含提交时间。如果智能体已经是 `blocked`,它不会发送输入,而是返回 `agent_blocked`。使用 `--wait` 时,从其他非 working 状态接受的提示必须在提交后的最多五秒内产生可观察的 `working` 或 `blocked` 状态,否则 Herdr 返回 `agent_prompt_stalled`;如果调用方的超时先到期,则返回普通的 `timeout` 错误。这可防止无关的 `idle`、`done` 或会话变化错误地完成等待。观察到活动后,它会等待请求的第一个稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。`--until` 用于缩小匹配状态,不带 `--wait` 时会被拒绝。独立的 `agent wait` 在当前状态匹配时立即返回。两者默认匹配 `idle`、`done` 或 `blocked`;需要 `unknown` 时请明确使用 `--until unknown`。 +`agent prompt` 遵循当前的 bracketed paste 模式,即使智能体处于 working,也会将文本和延迟的 Enter 作为一次有序提交写入。不带 `--wait` 时,成功只确认写入完成,不代表轮次已经开始。在 Windows 上,Codex 在 Enter 前会收到粘贴边界,因此提交不依赖提示大小;调用方的超时包含提交时间。如果智能体已经是 `blocked`,它不会发送输入,而是返回 `agent_blocked`。使用 `--wait` 时,从其他非 working 状态接受的提示必须在提交后的最多五秒内产生可观察的 `working` 或 `blocked` 状态,否则 Herdr 返回 `agent_prompt_stalled`;如果调用方的超时先到期,则返回普通的 `timeout` 错误。这可防止无关的 `idle`、`done` 或会话变化错误地完成等待。观察到活动后,它会等待请求的第一个稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。`--until` 用于缩小匹配状态,不带 `--wait` 时会被拒绝。独立的 `agent wait` 在当前状态匹配时立即返回。两者默认匹配 `idle`、`done` 或 `blocked`;需要 `unknown` 时请明确使用 `--until unknown`。 `idle` 和 `done` 都表示可以接受输入。CLI/API 使用服务器的已查看状态:`done` 表示尚未标记为已查看的 idle;显式 `pane focus` / `agent focus` 会标记目标,读取不会。每个 TUI 客户端独立记录它已显示的完成状态,因此其 Done 标记可能与 CLI 或其他客户端不同。`blocked` 表示 Herdr 识别到审批或提问界面。`unknown` 表示智能体存在但无法可靠分类,不代表工作成功。 @@ -367,6 +382,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration uninstall pi herdr integration uninstall omp @@ -382,6 +398,7 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration status [--outdated-only] ``` diff --git a/docs/preview/website/src/content/docs/zh-cn/configuration.mdx b/docs/preview/website/src/content/docs/zh-cn/configuration.mdx index 0a87b36a..9c1d5b2d 100644 --- a/docs/preview/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/configuration.mdx @@ -411,7 +411,7 @@ rows = [ ] ``` -只有第一条匹配的规则生效。指定的样式字段覆盖当前位置的默认值,未指定的字段保持继承。没有规则匹配时保留默认样式。匹配使用显示截断前的完整值,不改变文本、分隔符或可见性。没有样式字段的规则一旦匹配,就会停止后续匹配并保留默认样式。 +只有第一条匹配的规则生效。指定的样式字段覆盖当前位置的默认值,未指定的字段保持继承。没有规则匹配时保留默认样式。匹配使用显示截断前的完整值。在规则中设置 `hide = true` 可移除匹配的 token 及其分隔符;没有剩余 token 的行也会消失。例如,`{ token = "machine", fg = "#61afef", rules = [{ equals = "Local", hide = true }] }` 会在机器标签恰好为 `Local` 时隐藏机器 token。它比较的是标签,而不是连接类型。设置 `hide = false` 或省略时,token 保持可见。没有覆盖字段的规则一旦匹配,仍会停止后续匹配并保留默认值。 `equals`、`contains` 和 `starts_with` 接受字符串,默认区分大小写。添加 `ignore_case = true` 可忽略 ASCII 大小写,非 ASCII 字符仍区分大小写。空字符串遵循普通字符串匹配:空的 `equals` 只匹配空值,空的 `contains` 或 `starts_with` 匹配任何已存在的值。 @@ -508,7 +508,9 @@ claude = "on" ## Kitty graphics -Herdr 默认在兼容的外层终端中渲染窗格图像。要禁用图形渲染和窗格图形 API,请设置: +Herdr 默认在兼容的外层终端中渲染窗格图像。图像放置区域与弹窗、菜单或通知重叠时,Herdr 会暂时隐藏整个图像放置区域,而不是只裁剪重叠部分。未被遮挡的图像保持可见,被隐藏的图像放置区域在遮挡消失后恢复显示。图像不会随对话框背景一起变暗。 + +要禁用图形渲染和窗格图形 API,请设置: ```toml [terminal] diff --git a/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx b/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx index bb5cdc70..8180452b 100644 --- a/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx @@ -87,7 +87,7 @@ UI 默认使用客户端本地的主题、侧边栏设置和按键绑定。选 有多台机器时,默认智能体行会显示 `machine` token。已有的自定义行保持不变;需要机器标签时请显式加入 `machine`。[侧边栏行布局](/zh-cn/docs/configuration/)还支持按机器标签设置条件颜色。 -工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。两台机器可能都有 `w1:p1` 或名为 `reviewer` 的智能体。在 UI 中选择机器不会改变已有窗格中 CLI 命令的目标,它们仍使用继承的会话和 socket。远程自动化应在目标主机上针对目标会话执行命令,并在那里读取 ID。 +工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。两台机器可能都有 `w1:p1` 或名为 `reviewer` 的智能体。在 UI 中选择机器不会改变已有窗格中 CLI 命令的目标,它们仍使用继承的会话和 socket。远程自动化可使用 `herdr --machine agent list` 获取 ID,并在后续操作中使用同一前缀。CLI 直接使用保存的 SSH 目标和会话,不需要打开 TUI。省略 `--machine` 时现有行为不变。支持的命令、更新要求和远程路径规则见 [CLI 参考](/zh-cn/docs/cli-reference/)。 ## 更新与保存的数据 diff --git a/docs/preview/website/src/content/docs/zh-cn/integrations.mdx b/docs/preview/website/src/content/docs/zh-cn/integrations.mdx index ea0fc3d3..f6d7c1f3 100644 --- a/docs/preview/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/integrations.mdx @@ -1,6 +1,6 @@ --- title: 集成 -description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 +description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 --- Herdr 自动检测受支持的智能体。官方集成可以额外提供用于恢复的原生会话身份、生命周期状态上报,或两者兼有。 @@ -26,6 +26,7 @@ herdr integration install hermes herdr integration install mastracode herdr integration install qodercli herdr integration install qwen +herdr integration install letta herdr integration install cursor herdr integration install antigravity-cli herdr integration install grok @@ -48,11 +49,14 @@ herdr integration uninstall hermes herdr integration uninstall mastracode herdr integration uninstall qodercli herdr integration uninstall qwen +herdr integration uninstall letta herdr integration uninstall cursor herdr integration uninstall antigravity-cli herdr integration uninstall grok ``` +Herdr 对 Linux/macOS 上的集成配置和所有平台的新文件使用原子保存;Windows 上的现有文件先备份再原地更新,以保留权限。如果 Windows 提示存在备份,请在重试前关闭智能体并检查配置:未完成的 `.herdr-backup.pending` 只能在确认配置正确后删除,不能用于恢复;完整 `.herdr-backup` 的内容可在需要时写回现有文件(若文件已丢失,先设置其预期权限),确认配置正确后再删除备份。 + ## Herdr 如何使用集成 Herdr 以两种不同方式使用集成: @@ -60,7 +64,7 @@ Herdr 以两种不同方式使用集成: | 集成类型 | 智能体 | 效果 | | --- | --- | --- | | 生命周期权威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | 已安装且在为该窗格主动上报时,由钩子或插件事件决定 `idle`、`working` 和 `blocked`。对同一个生命周期权威,Herdr 不再使用屏幕清单兜底。 | -| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | +| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | 自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。 @@ -91,9 +95,9 @@ Herdr 以两种不同方式使用集成: [Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实实现示例。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放权威。 -一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 +一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 -原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `1`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 +原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Letta Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `2`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 ## Pi @@ -139,6 +143,8 @@ herdr integration install claude 该钩子在会话启动时把 Claude Code 的会话身份上报给本地 Herdr socket。Claude Code 的状态来自 Herdr 的屏幕清单检测。 +Claude 集成 v10 仅匹配文档列出的 `SessionStart` 来源: `startup`、`resume`、`clear`、`compact` 和 `fork`。这会阻止 Grok 的 `new` 和 `load` 事件启动导入的 Herdr Claude 钩子,而不会禁用其他 Claude 兼容钩子。升级后请重新安装 Claude 集成,以迁移原有的通配匹配规则。Claude 新增来源值时需要更新集成。 + Herdr 默认使用 `~/.claude`,设置了 `CLAUDE_CONFIG_DIR` 时使用后者。Claude 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`,并向 `settings.json` 添加 Herdr 钩子条目。卸载会移除匹配的钩子条目并删除钩子脚本。 ## Codex @@ -217,7 +223,11 @@ Herdr 的 Droid 钩子使用 `~/.factory`。Factory 配置目录必须已经存 herdr integration install opencode ``` -Herdr 把插件写入 `~/.config/opencode/plugins/herdr-agent-state.js`。OpenCode 配置目录必须已经存在。卸载只删除那个插件文件。 +该集成支持 OpenCode V1 `1.18.29` 及更高版本,以及 OpenCode V2(已验证 beta `19242`)。OpenCode 配置目录必须已经存在。Herdr 将服务端入口安装到 `~/.config/opencode/plugins/herdr-agent-state.js`,并在该配置目录安装共享 TUI 插件 `herdr-tui-session.js` 和 V2 TUI 入口 `herdr-opencode/tui.js`。 + +安装会在 `tui.jsonc` 中注册 V1 TUI 插件,并在 `cli.json` 中注册 V2 TUI 插件,同时保留其他设置和插件。如果 OpenCode 仍有 V1 TUI 偏好需要迁移(`tui.json` 或 `kv.json`),Herdr 会推迟注册,以便首次启动时执行迁移;此时请先启动一次 `opencode2`,然后重新安装集成。否则 Herdr 会创建 `cli.json` 并注册插件。安装后请重启 OpenCode TUI。卸载会删除受管理的插件文件及其配置条目。 + +V2 生命周期上报在窗格本地的 TUI 中运行。即使多个窗格共享同一个 OpenCode 服务端,事件也只归属于该 TUI 选中的根会话。完成和中断会清除工作状态;待处理的权限请求、表单以及执行失败会使窗格保持阻塞。V2 Mini 和无界面客户端不运行 TUI 插件,因此不提供此生命周期上报。 该插件在 OpenCode 运行于 Herdr 窗格内时上报生命周期状态和会话身份。在 OpenCode 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `opencode --session ` 恢复该窗格。插件未安装时,屏幕清单检测仍然可用。 @@ -275,6 +285,20 @@ Herdr 默认使用 `~/.qwen`,设置了 `QWEN_HOME` 时使用后者。Qwen 配置 Herdr 用 `qwen --resume ` 恢复保存的 Qwen Code 会话。 +## Letta Code + +安装 Letta Code 钩子: + +```bash +herdr integration install letta +``` + +静默的 `SessionStart` 钩子会上报会话 ID,用于原生恢复。Letta Code 状态仍来自 Herdr 的屏幕清单检测。 + +Herdr 使用 `~/.letta`。配置目录必须已经存在。安装会写入 `hooks/herdr-agent-session.sh`(Windows 上为 `hooks/herdr-agent-session.ps1`),并向 `settings.json` 添加 Herdr 条目。卸载只移除匹配条目和托管脚本。 + +Herdr 用 `letta --conversation ` 恢复命名会话。它把默认会话记录为 `default:`,并用 `letta --conversation default --agent ` 恢复该会话。 + ## Cursor Agent CLI 安装 Cursor Agent CLI 钩子: diff --git a/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx b/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx index d7b0d297..da795378 100644 --- a/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx @@ -66,6 +66,8 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智 按 `prefix+[` 让聚焦的窗格进入复制模式。用 `h/j/k/l`、tmux 风格的 `w/b/e` 和 `{`/`}` 移动。按 `/` 或 `?` 向前或向后进行文本搜索,再用 `n` 或 `N` 按相同或相反方向重复搜索。查询包含大写字母时区分大小写,否则不区分。用 `v` 或空格开始选择,用 `y` 或回车复制,用 `q` 或 Esc 不复制直接退出。Esc 会先清除当前选择或搜索,然后才退出。复制模式不会暂停窗格进程:停留在底部时继续跟随输出,进入历史记录后保持当前位置。鼠标拖选可以直接复制,完全不用进入复制模式。 +鼠标拖选以及通过 `v`、空格或 `V` 开始的选择会在输出和重绘期间保持有效,即使部分选中范围位于可视区域之外。复制读取的是该范围内当前的文本,而不是冻结的快照。调整窗格大小或在普通屏幕与备用屏幕之间切换仍会清除选择。搜索结果和双击单词选择仍保留内容变化检查。 + ## 一切都可以改 每个绑定都可以配置,包括前缀键本身: diff --git a/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx index b8e335a8..65567bc3 100644 --- a/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -27,6 +27,8 @@ herdr session delete side-project 命名会话拥有自己的窗格、标签页、工作区、socket 和运行时状态。它仍然共享同一个全局配置文件。 +删除会话时,请使用 `herdr session list` 显示的完整名称,大小写必须一致。在不区分大小写的文件系统上,`herdr session delete foo` 会拒绝删除名为 `Foo` 的会话。 + 脚本中使用 `--json`: ```bash diff --git a/docs/preview/website/src/content/docs/zh-cn/session-state.mdx b/docs/preview/website/src/content/docs/zh-cn/session-state.mdx index c046a3d4..9747e2b6 100644 --- a/docs/preview/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/session-state.mdx @@ -34,6 +34,10 @@ herdr 快照恢复不会保留运行中的 shell、服务器、测试或任意进程。无法使用更强恢复路径的窗格,会在各自保存的目录中作为新 shell 回来。 +如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。正常加载的会话不会创建恢复副本。 + +Herdr 保留最新的三个恢复副本,只有在新副本安全写入后才删除旧副本。如果无法保存副本,自动保存和退出保存都不会修改原始文件,并会记录错误,在下一次保存请求时重试。副本不会自动恢复。如需恢复,请停止对应的服务器,将恢复文件复制到该服务器的 `session.json`,然后重启。恢复副本不包含窗格屏幕历史。 + ## 窗格屏幕历史回放 窗格屏幕历史在服务器完全重启后恢复最近的终端内容。它恢复的是 Herdr 能展示的内容,而不是原来的进程。 @@ -77,6 +81,7 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 | Kimi Code CLI | `3` | `kimi --session ` | | Qoder CLI | `2` | `qodercli --resume ` | | Qwen Code | `1` | `qwen --resume ` | +| Letta Code | `1` | `letta --conversation `,或对 `default:` 使用 `letta --conversation default --agent ` | | OpenCode | `5` | `opencode --session ` | | Kilo Code CLI | `1` | `kilo --session ` | | Hermes Agent | `2` | `hermes --resume ` | diff --git a/docs/preview/website/src/content/docs/zh-cn/troubleshooting.mdx b/docs/preview/website/src/content/docs/zh-cn/troubleshooting.mdx index fe828a02..3d25e9e1 100644 --- a/docs/preview/website/src/content/docs/zh-cn/troubleshooting.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/troubleshooting.mdx @@ -78,7 +78,7 @@ herdr launchctl managername ``` -如果输出为 `Background`,请停止服务器,然后从普通 GUI 终端重新启动 Herdr: +自动启动的 macOS 服务器显示 `Background` 是正常的,不能仅凭此判断钥匙串访问有问题。如果钥匙串访问失败,且服务器是通过 SSH 或后台任务启动的,请停止服务器,然后从普通 GUI 终端重新启动 Herdr: ```bash herdr server stop @@ -87,6 +87,14 @@ herdr 停止服务器会结束窗格进程。Herdr 窗格会继承长期运行服务器的 macOS 启动上下文,因此通过 SSH 或后台任务启动的服务器可能无法访问交互式钥匙串服务。详情见 [Herdr issue #966](https://github.com/herdrdev/herdr/issues/966)。 +## macOS 注销后 SSH 或 DNS 失败 + +自动启动的 macOS 服务器使用用户级服务上下文,避免注销后窗格与 macOS 服务的连接失效。显式运行 `herdr server` 时仍会保留调用方的上下文。 + +旧服务器可能在注销后继续运行,但无法查询用户信息或解析 DNS。症状包括 `No user exists for uid 501`、`Could not get manager name.` 和 `curl: (6) Could not resolve host`。重新登录不能修复该服务器。请停止受影响的会话,然后从新登录会话的 GUI 终端启动 Herdr。这会结束正在运行的窗格进程,但会恢复保存的会话布局。实时移交会保留源服务器的服务上下文和现有窗格进程。因此,旧服务器需要完全重启会话才能应用此修复;移交无法修复继承的上下文。 + +如果新启动的服务器仍出现此问题,请在 `herdr-server.log` 中查找 `could not select persistent user service context`。所需的 macOS API 不可用时,Herdr 会保留继承的上下文。详情见 [Herdr issue #4100](https://github.com/herdrdev/herdr/issues/4100)。 + ## 找不到 `herdr` 命令 重启终端以重新加载环境,然后确认 Herdr 安装目录位于 `PATH` 中。通过软件包管理器安装的 Herdr 必须通过该管理器更新并加入环境。见[安装 Herdr](/zh-cn/docs/install/#verify)。 diff --git a/docs/preview/website/src/data/config-reference.json b/docs/preview/website/src/data/config-reference.json index 444f6a17..36afe337 100644 --- a/docs/preview/website/src/data/config-reference.json +++ b/docs/preview/website/src/data/config-reference.json @@ -1379,6 +1379,17 @@ "off" ] }, + { + "key": "ui.sound.agents.letta", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Letta Code agents.", + "values": [ + "default", + "on", + "off" + ] + }, { "key": "ui.sound.agents.maki", "type": "enum", @@ -1483,7 +1494,7 @@ "key": "experimental.cjk_ime_agents", "type": "list of strings", "default": "[]", - "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, qwen-code, maki." + "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, qwen-code, letta, letta-code, maki." }, { "key": "experimental.cjk_ime_cursor_shape", From 956f23ff6b72d16fe66169730b76bf565d12d449 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:40:20 +0000 Subject: [PATCH 02/99] release: synchronize metadata for v0.9.1 --- CHANGELOG.md | 59 + Cargo.lock | 2 +- Cargo.toml | 2 +- distribution/latest.json | 43 +- docs/next/CHANGELOG.md | 59 + .../src/content/docs/connecting-machines.mdx | 2 +- .../website/src/content/docs/integrations.mdx | 2 + .../src/content/docs/ja/cli-reference.mdx | 8 +- .../src/content/docs/ja/configuration.mdx | 4 +- .../content/docs/ja/connecting-machines.mdx | 14 +- .../src/content/docs/ja/integrations.mdx | 2 + .../website/src/content/docs/ja/keyboard.mdx | 18 + .../content/docs/ja/persistence-remote.mdx | 6 +- .../src/content/docs/ja/quick-start.mdx | 4 +- .../src/content/docs/ja/session-state.mdx | 3 + .../src/content/docs/ja/socket-api.mdx | 4 +- .../src/content/docs/ja/windows-beta.mdx | 7 +- .../website/src/content/docs/keyboard.mdx | 18 + .../src/content/docs/session-state.mdx | 2 + .../src/content/docs/zh-cn/cli-reference.mdx | 8 +- .../docs/zh-cn/connecting-machines.mdx | 14 +- .../src/content/docs/zh-cn/integrations.mdx | 2 + .../src/content/docs/zh-cn/keyboard.mdx | 18 + .../content/docs/zh-cn/persistence-remote.mdx | 6 +- .../src/content/docs/zh-cn/quick-start.mdx | 4 +- .../src/content/docs/zh-cn/session-state.mdx | 3 + .../src/content/docs/zh-cn/socket-api.mdx | 4 +- .../src/content/docs/zh-cn/windows-beta.mdx | 7 +- .../src/content/docs/agent-automation.mdx | 125 ++ .../website/src/content/docs/agent-skill.mdx | 67 + .../0.9.1/website/src/content/docs/agents.mdx | 159 ++ .../src/content/docs/cli-reference.mdx | 544 ++++++ .../website/src/content/docs/concepts.mdx | 89 + .../src/content/docs/config-reference.mdx | 21 + .../src/content/docs/configuration.mdx | 589 +++++++ .../src/content/docs/connecting-machines.mdx | 108 ++ .../website/src/content/docs/how-to-work.mdx | 103 ++ .../0.9.1/website/src/content/docs/index.mdx | 82 + .../website/src/content/docs/install.mdx | 154 ++ .../website/src/content/docs/integrations.mdx | 398 +++++ .../src/content/docs/ja/agent-automation.mdx | 125 ++ .../src/content/docs/ja/agent-skill.mdx | 67 + .../website/src/content/docs/ja/agents.mdx | 159 ++ .../src/content/docs/ja/cli-reference.mdx | 495 ++++++ .../website/src/content/docs/ja/concepts.mdx | 89 + .../src/content/docs/ja/config-reference.mdx | 27 + .../src/content/docs/ja/configuration.mdx | 584 +++++++ .../content/docs/ja/connecting-machines.mdx | 108 ++ .../src/content/docs/ja/how-to-work.mdx | 103 ++ .../website/src/content/docs/ja/index.mdx | 82 + .../website/src/content/docs/ja/install.mdx | 154 ++ .../src/content/docs/ja/integrations.mdx | 400 +++++ .../website/src/content/docs/ja/keyboard.mdx | 132 ++ .../src/content/docs/ja/marketplace.mdx | 52 + .../content/docs/ja/persistence-remote.mdx | 151 ++ .../website/src/content/docs/ja/plugins.mdx | 370 ++++ .../src/content/docs/ja/quick-start.mdx | 69 + .../src/content/docs/ja/session-state.mdx | 116 ++ .../src/content/docs/ja/socket-api.mdx | 754 ++++++++ .../src/content/docs/ja/troubleshooting.mdx | 120 ++ .../src/content/docs/ja/windows-beta.mdx | 147 ++ .../website/src/content/docs/keyboard.mdx | 132 ++ .../website/src/content/docs/marketplace.mdx | 54 + .../src/content/docs/persistence-remote.mdx | 159 ++ .../website/src/content/docs/plugins.mdx | 374 ++++ .../website/src/content/docs/quick-start.mdx | 69 + .../src/content/docs/session-state.mdx | 115 ++ .../website/src/content/docs/socket-api.mdx | 959 +++++++++++ .../src/content/docs/troubleshooting.mdx | 120 ++ .../website/src/content/docs/windows-beta.mdx | 147 ++ .../content/docs/zh-cn/agent-automation.mdx | 125 ++ .../src/content/docs/zh-cn/agent-skill.mdx | 67 + .../website/src/content/docs/zh-cn/agents.mdx | 159 ++ .../src/content/docs/zh-cn/cli-reference.mdx | 495 ++++++ .../src/content/docs/zh-cn/concepts.mdx | 89 + .../content/docs/zh-cn/config-reference.mdx | 26 + .../src/content/docs/zh-cn/configuration.mdx | 584 +++++++ .../docs/zh-cn/connecting-machines.mdx | 108 ++ .../src/content/docs/zh-cn/how-to-work.mdx | 103 ++ .../website/src/content/docs/zh-cn/index.mdx | 82 + .../src/content/docs/zh-cn/install.mdx | 154 ++ .../src/content/docs/zh-cn/integrations.mdx | 400 +++++ .../src/content/docs/zh-cn/keyboard.mdx | 132 ++ .../src/content/docs/zh-cn/marketplace.mdx | 44 + .../content/docs/zh-cn/persistence-remote.mdx | 151 ++ .../src/content/docs/zh-cn/plugins.mdx | 330 ++++ .../src/content/docs/zh-cn/quick-start.mdx | 69 + .../src/content/docs/zh-cn/session-state.mdx | 116 ++ .../src/content/docs/zh-cn/socket-api.mdx | 750 ++++++++ .../content/docs/zh-cn/troubleshooting.mdx | 120 ++ .../src/content/docs/zh-cn/windows-beta.mdx | 147 ++ .../website/src/data/config-reference.json | 1522 +++++++++++++++++ docs/versions/manifest.json | 8 +- skills/herdr/SKILL.md | 22 +- 94 files changed, 14948 insertions(+), 52 deletions(-) create mode 100644 docs/versions/0.9.1/website/src/content/docs/agent-automation.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/agent-skill.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/agents.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/cli-reference.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/concepts.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/config-reference.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/configuration.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/connecting-machines.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/how-to-work.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/index.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/install.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/integrations.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/agent-automation.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/agent-skill.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/agents.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/cli-reference.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/concepts.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/config-reference.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/configuration.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/connecting-machines.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/how-to-work.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/index.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/install.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/integrations.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/keyboard.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/marketplace.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/persistence-remote.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/plugins.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/quick-start.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/session-state.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/socket-api.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/troubleshooting.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/ja/windows-beta.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/keyboard.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/marketplace.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/persistence-remote.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/plugins.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/quick-start.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/session-state.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/socket-api.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/troubleshooting.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/windows-beta.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-automation.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-skill.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/agents.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/cli-reference.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/concepts.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/config-reference.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/configuration.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/connecting-machines.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/how-to-work.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/index.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/install.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/integrations.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/keyboard.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/marketplace.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/persistence-remote.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/plugins.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/quick-start.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/session-state.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/socket-api.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/troubleshooting.mdx create mode 100644 docs/versions/0.9.1/website/src/content/docs/zh-cn/windows-beta.mdx create mode 100644 docs/versions/0.9.1/website/src/data/config-reference.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a374b3..93c00199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ ## Unreleased +## [0.9.1] - 2026-09-16 + +### 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) + ## [0.9.0] - 2026-09-07 ### Added diff --git a/Cargo.lock b/Cargo.lock index 254c4ad7..0ba46fb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -671,7 +671,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "herdr" -version = "0.9.0" +version = "0.9.1" dependencies = [ "base64", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 25c71d05..09ceaf73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "herdr" -version = "0.9.0" +version = "0.9.1" edition = "2021" build = "build.rs" description = "terminal workspace manager for AI coding agents" 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/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index e6a374b3..93c00199 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,65 @@ ## Unreleased +## [0.9.1] - 2026-09-16 + +### 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) + ## [0.9.0] - 2026-09-07 ### Added diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 3a31639d..436c2c2a 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -17,7 +17,7 @@ ssh workbox `workbox` can be a host from your SSH config. You can also use a target such as `ssh://you@server:2222`. -Multi-machine connections are supported on Linux and macOS clients, connecting to Linux and macOS servers on x86_64 or aarch64. Multi-machine connections are not yet verified or supported on Windows; standalone `herdr --remote` remains supported on Windows. Native Windows servers are not supported as SSH targets. See [Remote attach over SSH](/docs/persistence-remote/#remote-attach-over-ssh) for SSH configuration, authentication, and custom binaries. +Multi-machine connections support Linux, macOS, and Windows clients connecting to Linux or macOS servers on x86_64 or aarch64, or Windows servers on x86_64. Interactive setup can install or update the complete remote package after confirmation; background reconnects only discover installed packages. See [Remote attach over SSH](/docs/persistence-remote/#remote-attach-over-ssh) for SSH configuration, authentication, and custom binaries. ## Add a machine diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 4516061f..e584bb06 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -285,6 +285,8 @@ Herdr resumes stored Qwen Code sessions with `qwen --resume `. ## Letta Code +Letta Code integration installation is experimental and CLI-only. It is not listed in the Settings integration screen or the JSON integration API. `herdr integration status` reports it as `letta (experimental)`. Agent detection and `herdr agent start --kind letta` are supported independently of this installation restriction. + Install the Letta Code hook: ```bash diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 58cb3e9f..fbda5674 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -218,7 +218,7 @@ herdr pane move --new-workspace [--label TEXT] [--tab-label TEXT] [--f herdr pane close ``` -`--current` を受け付けるペインコマンドは、Herdr ペイン内から実行した場合、呼び出し元の `HERDR_PANE_ID` を使います。`pane split` では、明示したペイン ID または `--pane ID` がそのペインを、`--current` が呼び出し元ペインを分割します。対象を省略すると UI でフォーカス中のペインを使います。分割レスポンスでは、新しいペイン ID は `.result.pane.pane_id` にあります。 +`--current` を受け付けるペインコマンドは、Herdr ペイン内から実行した場合、呼び出し元の `HERDR_PANE_ID` を使います。`pane split` では、明示したペイン ID または `--pane ID` がそのペインを分割します。対象を省略すると `HERDR_PANE_ID` があれば呼び出し元ペインを、なければフォーカス中のペインを使います。`--current` は呼び出し元ペインを必須とし、`HERDR_PANE_ID` がない場合はエラーになります。分割レスポンスでは、新しいペイン ID は `.result.pane.pane_id` にあります。 `pane move` の後続コマンドには `.result.move_result.pane.pane_id` を使います。別ワークスペースへの移動ではワークスペース修飾付きのペイン ID が変わり、以前の値は `.result.move_result.previous_pane_id` に残ります。実行中プロセスの `HERDR_PANE_ID`、`HERDR_TAB_ID`、`HERDR_WORKSPACE_ID` は起動時の値のままですが、Herdr は以前のペイン ID をそのターミナルの別名として保持するため、`--current` を使うペインコマンドは引き続き解決できます。ライブエージェント名もターミナルに追従します。 @@ -342,11 +342,17 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] ```bash herdr terminal attach [--takeover] +herdr terminal session control [--takeover] [--cols N] [--rows N] +herdr terminal session observe [--cols N] [--rows N] herdr terminal title set herdr terminal title clear ``` ダイレクトアタッチからは `ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 +`terminal session control` は、ペイン、ターミナル、エージェントを対象に書き込み可能なライブストリームを開きます。observe と同じ改行区切り JSON の `terminal.frame` と `terminal.closed` を出力し、標準入力から `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` を受け取ります。制御クライアントは同時に 1 つだけで、`--takeover` で置き換えられます。 + +`terminal session observe` は同じ対象の読み取り専用ストリームを開きます。base64 の ANSI バイト列を含む `terminal.frame` と、サーバー終了時の `terminal.closed` を改行区切り JSON で出力します。複数の観測クライアントは入力、リサイズ、スクロール、制御権を取得せずに同じターミナルを監視できます。Linux/macOS ではソケットへの書き込みが 30 秒間進まない観測クライアントを切断します。その場合は最後の `terminal.closed` が届かないことがありますが、ペインや他のクライアントは動き続けます。出力のない静かなペインはこのタイムアウトの対象ではありません。 + `terminal title clear` は外側ターミナルウィンドウタイトルを `ui.window_title` に戻します。 ## 出力待機 diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index 7867e8c4..12802e84 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -508,7 +508,9 @@ claude = "on" ## Kitty graphics -Herdr は、対応する外側のターミナルでペイン画像をデフォルトで描画します。グラフィックス描画とペイングラフィックス API を無効にするには、次のように設定します: +Herdr は、対応する外側のターミナルでペイン画像をデフォルトで描画します。ポップアップ、メニュー、通知は、重なる画像の配置だけを一時的に隠します。重ならない画像は表示されたままで、隠れた配置は覆いがなくなると戻ります。画像はダイアログの背景と一緒に暗くなりません。 + +グラフィックス描画とペイングラフィックス API を無効にするには、次のように設定します: ```toml [terminal] diff --git a/docs/next/website/src/content/docs/ja/connecting-machines.mdx b/docs/next/website/src/content/docs/ja/connecting-machines.mdx index 014e670f..2c3a41cc 100644 --- a/docs/next/website/src/content/docs/ja/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/ja/connecting-machines.mdx @@ -17,7 +17,7 @@ ssh workbox `workbox` は SSH config のホスト名です。`ssh://you@server:2222` のような接続先も使えます。 -複数マシンへの接続は Linux と macOS のクライアントで対応しており、x86_64 または aarch64 の Linux と macOS サーバーに接続できます。Windows での複数マシンへの接続は未検証・未対応です。単独接続の `herdr --remote` は引き続き Windows でも対応しています。ネイティブ Windows サーバーは SSH 接続先として未対応です。SSH 設定、認証、カスタムバイナリについては [SSH 越しのリモートアタッチ](/ja/docs/persistence-remote/)を参照してください。 +複数マシンへの接続は Linux、macOS、Windows のクライアントに対応し、x86_64 または aarch64 の Linux と macOS サーバー、および x86_64 の Windows サーバーに接続できます。対話型セットアップでは確認後に完全なリモートパッケージをインストールまたは更新できます。バックグラウンド再接続ではインストール済みのパッケージだけを探します。SSH 設定、認証、カスタムバイナリについては [SSH 越しのリモートアタッチ](/ja/docs/persistence-remote/)を参照してください。 ## マシンを追加する @@ -43,7 +43,13 @@ Herdr はインストール済みバイナリと実行中サーバーをそれ サイドバーでマシンまたはそのワークスペースを選びます。選択中のマシンだけがペイン入力とターミナルサイズを受け取り、表示するターミナル内容とグラフィックスを送信します。ほかの接続済みマシンは、ペイン画面を送らずにワークスペース情報、エージェント状態、通知を更新し続けます。 -起動時は SSH 接続を待たずに Local を開きます。応答しないマシンがほかのマシンの入力を妨げることはありません。同じサーバーに接続した複数のクライアントは、それぞれ別のタブを表示できます。同じタブを表示した場合のサイズについては[クライアントとサーバー](/ja/docs/concepts/)を参照してください。 +各マシンの横の矢印をクリックすると、現在のワークスペースを切り替えずに一覧を折りたためます。再接続中のマシンでも操作できます。 + +キーボードでは `prefix+w` を押し、ワークスペース移動キー(デフォルトは Up/Down)で接続済みマシンのワークスペースをサイドバー順に選びます。Enter で移動を確定し、Esc またはプレフィックスキーで切り替えずにキャンセルします。縮小・展開サイドバーとも、折りたたまれたマシン内の選択行を表示します。デスクトップでは両端で折り返し、モバイルの切り替え画面では先頭と末尾で止まります。切断中のマシンは飛ばします。 + +別マシンのワークスペースをハイライトしたら、他のキー操作の前に Enter で切り替えてください。現在のマシンでペイン、タブ、ワークスペース、カスタムコマンドの操作が誤って実行されるのを防ぎます。クリックすると、デスクトップでのリモート移動のプレビューはキャンセルされます。展開サイドバーはマシンごとの worktree グループの折りたたみ状態を反映します。 + +起動時は SSH 接続を待たずに Local を開きます。Local を選ぶと、リモートの応答を待たずに未完了の切り替えをキャンセルします。Local 自体が再接続中なら、準備ができた時点で選択を再開します。Local の新しい画面とターミナル設定が届くと入力できます。応答しないマシンがほかのマシンの入力を妨げることはありません。同じサーバーに接続した複数のクライアントは、それぞれ別のタブを表示できます。同じタブを表示した場合のサイズについては[クライアントとサーバー](/ja/docs/concepts/)を参照してください。 接続が切れると、最後のワークスペースとエージェント状態を暗く表示します。これはキャッシュであり、現在の状態ではありません。新しい接続と対応する画面が届くまで、そのペインへの入力や移動は無効です。再接続しても、使用中のマシンから選択を奪いません。 @@ -67,10 +73,12 @@ herdr machine remove <profile-id> ## 接続の問題 -- **Reconnecting:** ネットワーク中断、スリープ、SSH 障害の後は、上限のある待機間隔で再試行します。SSH 接続はアプリケーションの通信を監視し、通信がない間はヘルスチェックを行うため、切れた接続が Online のままになりません。Local はリモート用のプローブではなく、ネイティブ接続の終了や失敗を検知します。 +- **Reconnecting:** ネットワーク中断、スリープ、SSH 障害の後は自動で再試行します。失敗が続くと待機間隔は最大 2 分まで延び、短時間だけ接続に成功してもリセットされません。1 分間安定して接続できると、次の中断では再び短い間隔で再試行します。SSH 接続はアプリケーションの通信を監視し、通信がない間はヘルスチェックを行うため、切れた接続が Online のままになりません。Local はリモート用のプローブではなく、ネイティブ接続の終了や失敗を検知します。 - **Attention:** ホスト鍵の承認、認証、互換サーバーの準備など、バックグラウンドではできない操作が必要です。ほかのマシンは利用できます。 - **保存済みマシンのファイルエラー:** カタログを読み取れない、または内容が不正な場合、現在の接続は変更しません。通知を表示して自動で読み取りを再試行します。 +両端のインストールがブリッジのアイドル終了に対応している場合、Linux と macOS への保存済みマシン接続は、どちらの方向にも通信がない状態が 1 分続くとリモートブリッジを終了します。スリープ時間も含み、復帰時に期限を確認します。正常なアイドル接続ではヘルスチェックを交換するため、継続的な出力を見るためにキーを入力する必要はありません。ブリッジ終了後もリモートサーバー、セッション、ペインのプロセスは動き続けます。古いインストールとは、この追加機能なしで接続できます。 + バックグラウンド接続がプロンプトに回答したり、サーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。Attention の場合は、Herdr が表示する単独接続用のコマンドを対話型ターミナルで実行してください。例: ```bash diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index 75cebbe4..6086e6b6 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -287,6 +287,8 @@ Herdr は保存された Qwen Code セッションを `qwen --resume <id>` で r ## Letta Code +Letta Code インテグレーションのインストールは実験的な CLI 専用機能です。Settings のインテグレーション一覧や JSON インテグレーション API には含まれません。`herdr integration status` では `letta (experimental)` と表示されます。エージェント検出と `herdr agent start --kind letta` は、このインストール方法の制限とは別にサポートされています。 + Letta Code フックをインストールします: ```bash diff --git a/docs/next/website/src/content/docs/ja/keyboard.mdx b/docs/next/website/src/content/docs/ja/keyboard.mdx index 97ce6b5c..2e547b64 100644 --- a/docs/next/website/src/content/docs/ja/keyboard.mdx +++ b/docs/next/website/src/content/docs/ja/keyboard.mdx @@ -62,6 +62,24 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚 完全なキーマップとバインドの構文は[キーバインドリファレンス](/ja/docs/configuration/#キーバインド)にあります。 +## テキスト欄の編集 + +Herdr の名前入力、worktree のブランチ名、フィルター、コピーモードの検索欄では、カーソル位置で編集できます。以下は Herdr 自身の入力欄の操作であり、ペイン内のシェルやエージェントの操作は変わりません。 + +| 操作 | キー | +| --- | --- | +| 1 文字移動 | Left / Right、Ctrl+B / Ctrl+F | +| 先頭 / 末尾へ移動 | Home / End、Ctrl+A / Ctrl+E | +| 1 単語移動 | Alt+B / Alt+F | +| 前の文字を削除 | Backspace、Ctrl+H | +| 次の文字を削除 | Delete、Ctrl+D | +| 先頭 / 末尾まで切り取り | Ctrl+U / Ctrl+K | +| 前の単語を切り取り | Ctrl+W、Alt+Backspace、Ctrl+Backspace | +| 次の単語を切り取り | Alt+D | +| 最後に切り取ったテキストを挿入 | Ctrl+Y | + +入力と貼り付けはカーソル位置に挿入されます。移動と削除は Unicode の書記素境界を尊重し、長い値は横にスクロールします。切り取ったテキストは現在の入力欄だけで保持し、システムのクリップボードは使いません。Alt ショートカットにはターミナルの Alt/Meta 報告が必要です。Enter と Esc は引き続きダイアログの確定とキャンセルに使います。 + ## コピーモード `prefix+[` を押すと、フォーカス中のペインでコピーモードに入ります。`h/j/k/l`、tmux 形式の `w/b/e`、`{`/`}` で移動します。`/` または `?` で前方または後方のリテラル検索を開始し、`n` または `N` で同じ方向または逆方向に繰り返します。クエリに大文字が含まれる場合だけ大文字と小文字を区別します。`v` または Space で選択を開始し、`y` または Enter でコピーし、`q` または Esc でコピーせずに抜けます。Esc は終了する前に、アクティブな選択または検索を消します。コピーモードはペインのプロセスを停止しません。最下部では出力を追従し、履歴へ移動するとその位置を保ちます。マウスのドラッグ選択なら、コピーモードに入らずにそのままコピーできます。 diff --git a/docs/next/website/src/content/docs/ja/persistence-remote.mdx b/docs/next/website/src/content/docs/ja/persistence-remote.mdx index acd75b86..94bffb8b 100644 --- a/docs/next/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/ja/persistence-remote.mdx @@ -69,7 +69,7 @@ Host workbox herdr --remote workbox ``` -リモートアタッチは、Linux、macOS、Windows のローカルクライアントから、x86_64 と aarch64 の Linux および macOS ホストへの接続をサポートします。Herdr はリモートのプラットフォームを確認し、リモートの `PATH` 上にある互換性のある `herdr` を優先してから、一般的な直接インストール、Homebrew、mise、Nix プロファイルのパスを確認します。両方が安定したエンドポイント世代に対応していれば、ローカルとリモートのバージョンは一致しなくても接続できます。互換性のあるバイナリがない場合、対話的な実行では `~/.local/bin/herdr` へのインストールを提案します。非対話的な実行はホストを変更せずに失敗します。`~/.local/bin` がリモートの `PATH` にない場合、Herdr はインストール後に警告します。Windows をリモートホストとして使うことはできません。 +リモートアタッチと保存済み SSH マシンは、Linux、macOS、Windows のクライアントから、x86_64 または aarch64 の Linux/macOS ホスト、および x86_64 の Windows ホストへの接続に対応します。両端が安定したエンドポイント世代に対応していれば、バージョンは一致しなくても構いません。Linux/macOS ではリモートの `PATH` 上の互換バイナリを優先し、次に一般的な直接インストール、Homebrew、mise、Nix のパスを確認します。Windows では `PATH` と現在有効な管理対象リリースを確認します。互換バイナリがなければ、対話的な実行はインストールや更新の確認を求め、非対話的な実行はホストを変更せず失敗します。Linux/macOS では `~/.local/bin/herdr` に、Windows ではアプリローカル ConPTY ランタイムを含む完全なパッケージをインストールします。リモートの `PATH` に `~/.local/bin` がない場合は警告します。 デフォルトでは、`herdr --remote` はユーザーの SSH config を最初に include し、その後にフォールバックのキープアライブ設定を加えた一時的な SSH config を通してリモートセットアップとブリッジを実行します。既存のユーザーのキープアライブ設定が優先されます。Linux と macOS クライアントは接続再利用用のプライベートなアタッチ単位コントロールソケットも使いますが、Windows OpenSSH では使いません。Herdr が生成する設定やコントロールソケットを使わず通常の `ssh` を使うには `[remote].manage_ssh_config = false` を設定してください。 @@ -90,9 +90,9 @@ herdr --remote workbox --handoff 先にサーバーへ SSH してそこで `herdr` を実行した場合、Herdr は完全にサーバー上で動きます。このモードは便利でシンプルですが、通常のターミナルテキスト貼り付けを超えてローカルデスクトップのクリップボードにはアクセスできません。 -ローカルとリモートのプラットフォームが一致する場合、直接インストールでは Herdr は現在のローカルバイナリをコピーできます。Homebrew、mise、Nix のインストールの場合、またはプラットフォームが異なる場合は、`https://herdr.dev/latest.json` から現在のクライアントバージョンに一致するリリースアセットをダウンロードします。 +Linux/macOS ホストでは、プラットフォームが一致する直接インストールなら現在のローカルバイナリをコピーできます。Homebrew、mise、Nix のインストール、異なるプラットフォーム、または Windows ホストの場合は、`https://herdr.dev/latest.json` から現在のクライアントバージョンに対応するリリースアセットをダウンロードします。 -ローカルビルドやカスタムバイナリの場合は、リモートアタッチの前に `HERDR_REMOTE_BINARY` にローカルファイルパスを設定してください。 +ローカルビルドやカスタムパッケージを使うには、リモートアタッチ前に `HERDR_REMOTE_BINARY` にローカルファイルパスを設定します。Linux/macOS 向けには実行ファイル、Windows 向けには完全な `herdr-windows-x86_64.zip` パッケージを指定してください。 ```bash HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox diff --git a/docs/next/website/src/content/docs/ja/quick-start.mdx b/docs/next/website/src/content/docs/ja/quick-start.mdx index 4de15f61..e5e873ce 100644 --- a/docs/next/website/src/content/docs/ja/quick-start.mdx +++ b/docs/next/website/src/content/docs/ja/quick-start.mdx @@ -17,9 +17,9 @@ Herdr はデフォルトのバックグラウンドセッションを起動す ## マウスを使う -Herdr はマウスネイティブなので、まずはクリックから始めてください。ペイン、タブ、ワークスペース、エージェントをクリックしてフォーカスします。分割境界をドラッグしてリサイズします。右クリックでコンテキストメニューが開き、ペインの分割やタブの作成ができます。テキストをドラッグ選択するとクリップボードにコピーされます。トークンをダブルクリックすると直接コピーされます。コピーに Ctrl+C は不要です。 +Herdr はマウスネイティブなので、まずはクリックから始めてください。ペイン、タブ、ワークスペース、エージェントをクリックしてフォーカスします。分割境界をドラッグしてリサイズします。右クリックでコンテキストメニューが開き、ペインの分割やタブの作成ができます。テキストをドラッグ選択するとクリップボードにコピーされます。トークンをダブルクリックすると選択し、2 回目の押下を保持してドラッグすると単語単位で選択を広げられます。どちらもマウスを離すとコピーされます。コピーに Ctrl+C は不要です。 -ターミナルが修飾キー付きクリックを Herdr に渡す場合、Ctrl+クリックでペイン内のリンクを開けます。これは OSC 8 ハイパーリンクと、表示されている `http://` または `https://` の URL で機能します。macOS では、マウスキャプチャが有効な間は Herdr が処理するペインリンクに Ctrl+クリックを使ってください。Cmd+クリックは Shift+Cmd+クリックや `ui.mouse_capture = false` といったターミナルネイティブのバイパス経路でのみ使えます。 +ターミナルが修飾キー付きクリックを Herdr に渡す場合、Ctrl+クリックでペイン内のリンクを開けます。OSC 8 ハイパーリンクと `http://` または `https://` の URL に対応し、折り返されたリンクの先頭や末尾が表示範囲外でも開けます。Ctrl を押しながらリンク上にポインターを移動すると、表示中の部分に下線が付きます。ポインターを外す、ペイン内容が変わる、または Ctrl の解放イベントを受け取ると下線は消えます。一部のターミナルでは、修飾キーの変化はマウス移動時にだけ報告されます。macOS では、マウスキャプチャが有効な間は Herdr が処理するペインリンクに Ctrl+クリックを使ってください。Cmd+クリックは Shift+Cmd+クリックや `ui.mouse_capture = false` といったターミナルネイティブのバイパス経路でのみ使えます。 `ui.right_click_passthrough_modifier` を設定すると、その修飾キー + 右クリックで、右クリック・ホールド・ドラッグのジェスチャーがマウスレポーティング対応のペインアプリに送られます。 diff --git a/docs/next/website/src/content/docs/ja/session-state.mdx b/docs/next/website/src/content/docs/ja/session-state.mdx index b2d182b0..006ba439 100644 --- a/docs/next/website/src/content/docs/ja/session-state.mdx +++ b/docs/next/website/src/content/docs/ja/session-state.mdx @@ -75,6 +75,7 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ | Claude Code | `6` | `claude --resume <id>` | | Codex | `5` | `codex resume <id>` | | Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | +| Grok CLI | `2` | `grok --resume <id>` | | GitHub Copilot CLI | `2` | `copilot --resume=<id>` | | Devin CLI | `2` | `devin --resume <id>` | | Droid | `2` | `droid --resume <id>` | @@ -101,6 +102,8 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ ハンドオフが保護するのは、ペインの PTY とプロセス、エージェントの識別情報と永続メタデータ、交換後のサーバーに必要なプラグイン/セッション状態など、サーバーが所有する長寿命のセッション状態です。交換境界をまたぐ一時的な協調状態は保持しません。処理中の CLI/API リクエスト、wait、購読ストリーム、クライアントソケット、ペイン間メッセージは中断される可能性があるため、クライアントは再接続して再試行してください。 +更新済みの Unix サーバーは、ペインを分割して転送することで 64 個を超えるペインを 1 回のハンドオフで送信できます。送信側サーバーがすでにこの機能を備えている必要があります。古いサーバーからの最初の更新では、引き続き 64 ペインの制限が適用されます。その場合は通常のサーバー再起動が必要になることがあり、ペインのプロセスは終了します。分割転送によって更新のインストール順序や他の互換性要件が変わるわけではありません。 + ライブハンドオフは実験的機能で、オプトインです: ```bash diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index c57b3ace..a556100c 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -348,7 +348,7 @@ worktree コマンドはライフサイクルイベントも発行します。`w フィールドとして `{"token":"name"}` を使います。値には文字列、真偽値、符号なし整数、 またはコンテキストオブジェクトを指定できます。コンテキスト値は `current_workspace_id` と `current_tab_id` で、対応する ID フィールドとの比較にだけ -使えます。有効な status は `idle`、`working`、`blocked`、`done`、`unknown` です。 +使えます。保存済みマシンに接続するクライアントでは、選択中サーバーのビューを統合エージェント一覧に適用します。現在のワークスペースとタブのコンテキストには選択中のマシンも含まれるため、別マシンの同じ ID は一致しません。一方、`status` が `blocked` かどうかなど独立したフィルター条件は、接続中のすべてのマシンに一致できます。有効な status は `idle`、`working`、`blocked`、`done`、`unknown` です。 `done` は idle かつ未確認の状態を表します。 ソートフィールドは `workspace_order`、`tab_order`、`pane_order`、`attention`、 @@ -641,7 +641,7 @@ workspace の get/list 応答は結果の `tokens` マップを公開し、ス ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。`workspace.reordered` は、アトミックに移動した `workspace_ids`、省略可能な `before_workspace_id`、サーバーが確定した順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。 タブのイベント購読には `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed`、`tab.moved` があります。`tab.moved` は、移動した `tab_id`、`workspace_id`、要求された `insert_index`、そのワークスペースの更新済みの順序付き `tabs` リストを含みます。 -ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。 +ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。`pane.focused` は、このサーバーに接続した任意のクライアントでの手動ペイン選択も報告します。他のクライアントの表示は移動させず、ペイロードにクライアントの識別情報は含みません。選択済みのペインを再度選んでも発行しません。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。 レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でブートストラップするクライアントは、同じ `workspace_id` と `tab_id` のキャッシュ済みレイアウトを置き換えてください。 worktree のイベント購読には `worktree.created`、`worktree.opened`、`worktree.removed` があります。worktree イベントは Git チェックアウトのライフサイクルを記述します。`worktree.created` は開かれた `workspace` と作成された `worktree` を含みます。`worktree.opened` は対象の `workspace`、開かれた `worktree`、`already_open` を含みます。`worktree.removed` は `workspace_id`、削除された `worktree`、`forced` を含みます。 diff --git a/docs/next/website/src/content/docs/ja/windows-beta.mdx b/docs/next/website/src/content/docs/ja/windows-beta.mdx index 7d51fe0d..db708133 100644 --- a/docs/next/website/src/content/docs/ja/windows-beta.mdx +++ b/docs/next/website/src/content/docs/ja/windows-beta.mdx @@ -32,7 +32,7 @@ Windows ビルドは安定版とプレビューの両方のアップデートチ | ローカル永続セッション | サポート済み | | ConPTY によるネイティブペイン | サポート済み | | Windows Terminal / PowerShell アプリからのアタッチ | サポート済み | -| Linux/macOS ホストへの `herdr --remote` | サポート済み | +| Linux/macOS/Windows ホストへの `herdr --remote` と保存済み SSH マシン | サポート済み。対話的なアタッチとマシン追加では確認後に Windows パッケージをインストール・更新できます。バックグラウンド再接続ではインストール済みパッケージだけを探します | | リモートクリップボード画像と画像ファイルのドロップ | サポート済み | | `cmd.exe` ペイン | サポート済み | | ネイティブのキーボード/マウス入力 | サポート済み | @@ -80,7 +80,7 @@ switch_ascii_input_source_in_prefix = true その他の Windows IME はまだこのオプションに対応していません。[設定](/ja/docs/configuration/#プレフィックス入力ソースの切り替え)を参照してください。 -Windows 上の一部のエージェントは `ctrl+v` を受け取り、クリップボード画像を直接読み取れます。Herdr 自身のクリップボード画像リーダーはローカルのネイティブ Windows ペインには配線されていないため、エージェント自身による画像貼り付けはターミナルとエージェントに依存します。`alt+v` などエージェントの画像貼り付けショートカットによって、Herdr が管理するローカルクリップボードブリッジが追加されるわけではありません。リモートクリップボード画像ブリッジは、Linux/macOS ホストへの `herdr --remote` で別途サポートされます。 +Windows 上の一部のエージェントは `ctrl+v` を受け取り、クリップボード画像を直接読み取れます。Herdr 自身のクリップボード画像リーダーはローカルのネイティブ Windows ペインには配線されていないため、エージェント自身による画像貼り付けはターミナルとエージェントに依存します。`alt+v` などエージェントの画像貼り付けショートカットによって、Herdr が管理するローカルクリップボードブリッジが追加されるわけではありません。リモートクリップボード画像ブリッジは、Linux/macOS/Windows ホストへの `herdr --remote` で別途サポートされます。 Kitty graphics はデフォルトで有効で、外側のターミナルに依存します。Herdr は他のプラットフォームと同様に Windows でも Kitty graphics protocol の出力を送ります。この経路は Windows の WezTerm から WSL 内の Herdr をホストする構成で動作確認されていますが、ネイティブ Windows のターミナルと ConPTY の組み合わせはすべて検証済みではありません。Windows Terminal は Herdr が使う Kitty graphics 経路を提供しません。外側のターミナルがグラフィックス出力を正しく扱えない場合は、`[terminal].kitty_graphics = false` に設定してください。 @@ -118,7 +118,6 @@ Herdr のペインテキストコピーは Windows で動作します。ペイ | 機能 | 状況 | | --- | --- | | ダイレクトターミナルアタッチ (`herdr terminal attach`) | 未サポート | -| `herdr --remote` の接続先としての Windows | 未サポート | | ライブサーバーハンドオフ | 未サポート | | Unix ファイルディスクリプタのハンドオフ | 未サポート | | Unix フォアグラウンドプロセスグループ | 未サポート | @@ -131,7 +130,7 @@ Windows Terminal からは、Linux や macOS と同じリモートコマンド herdr --remote workbox ``` -接続先ホストは Linux または macOS である必要があります。Herdr はインストール済みの Windows OpenSSH クライアントと SSH 設定を使います。Windows OpenSSH では Herdr の Unix コントロールソケットによる接続再利用を使わないため、セットアップ中の繰り返しプロンプトを避けるには Windows の `ssh-agent` による鍵認証を推奨します。 +接続先ホストは Linux、macOS、Windows に対応します。Windows ホストでは `PATH` または現在有効な管理対象リリースから互換パッケージを再利用します。対話的なアタッチと保存済みマシンのセットアップは、必要に応じて完全なパッケージをインストール・更新する前に確認します。バックグラウンド再接続ではインストール済みパッケージだけを探し、インストールや更新の確認は行いません。Herdr はインストール済みの Windows OpenSSH クライアントと SSH 設定を使います。Windows OpenSSH では Herdr の Unix コントロールソケットによる接続再利用を使わないため、セットアップ中の繰り返しプロンプトを避けるには Windows の `ssh-agent` による鍵認証を推奨します。 Windows のアップデートは Windows インストーラー経由で行われ、有効なバージョン付きリリースパスを更新します。新しいターミナルまたは再接続した SSH セッションはそのパスを受け取るため、そこで Herdr を起動すると更新済みクライアントを使えます。互換性のある実行中サーバーはペインを動かしたままにします。新しいリリースのサーバー側変更が必要になったときだけ、後からサーバーを再起動してください。ライブハンドオフは Unix 専用です。 diff --git a/docs/next/website/src/content/docs/keyboard.mdx b/docs/next/website/src/content/docs/keyboard.mdx index d20b34b3..f1e1394c 100644 --- a/docs/next/website/src/content/docs/keyboard.mdx +++ b/docs/next/website/src/content/docs/keyboard.mdx @@ -62,6 +62,24 @@ Workspaces and session: The full keymap and the binding syntax live in the [keybinding reference](/docs/configuration/#keybindings). +## Editing text fields + +Herdr's naming dialogs, worktree branch fields, filters, and copy-mode search support editing at the cursor. These shortcuts apply to Herdr-owned fields, not to the shell or agent inside a pane. + +| Action | Keys | +| --- | --- | +| Move one character | Left / Right, Ctrl+B / Ctrl+F | +| Move to the beginning / end | Home / End, Ctrl+A / Ctrl+E | +| Move one word | Alt+B / Alt+F | +| Delete the previous character | Backspace, Ctrl+H | +| Delete the next character | Delete, Ctrl+D | +| Cut to the beginning / end | Ctrl+U / Ctrl+K | +| Cut the previous word | Ctrl+W, Alt+Backspace, Ctrl+Backspace | +| Cut the next word | Alt+D | +| Insert the last cut text | Ctrl+Y | + +Typing and paste insert at the cursor. Movement and deletion respect Unicode grapheme boundaries, and long values scroll horizontally. Cut text stays within the current field; it does not use the system clipboard. Alt shortcuts require the terminal to report Alt/Meta. Enter and Esc retain the dialog's confirm and cancel behavior. + ## Copy mode Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, tmux-style `w/b/e` and big-word `W/B/E`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move. Press `/` or `?` for forward or backward literal search, then `n` or `N` to repeat in the same or opposite direction. Search is case-insensitive unless the query contains an uppercase letter. Use `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. Esc clears an active selection or search before exiting. Copy mode does not pause the pane process: output remains live, follows at the bottom, and stays pinned when you navigate into history. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index 38712689..fd065483 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -101,6 +101,8 @@ Unlike snapshot restore, pane history replay, and native agent session restore, A successful handoff preserves long-lived server-owned session state: pane PTYs and processes, agent identity and durable metadata, and plugin/session state needed by the replacement server. It does not preserve transient coordination across the replacement boundary. In-flight CLI or API requests, waits, subscription streams, client sockets, and pane-to-pane messages may be interrupted; clients should reconnect and retry them. +Updated Unix servers can send more than 64 panes in one handoff by transferring them in batches. The sending server must already contain this support: an older running server still enforces its 64-pane limit on the first upgrade. That limit may require a normal server restart, which ends pane processes. Batching does not change update installation order or remove other handoff compatibility requirements. + Live handoff is experimental and opt-in: ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 8795c45e..4c7df02a 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -218,7 +218,7 @@ herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--f herdr pane close <pane_id> ``` -接受 `--current` 的窗格命令在 Herdr 窗格内运行时使用调用方的 `HERDR_PANE_ID`。对 `pane split` 而言,显式窗格 ID 或 `--pane ID` 拆分该窗格,`--current` 拆分调用方窗格,省略目标则使用 UI 当前聚焦的窗格。拆分响应中的新窗格 ID 位于 `.result.pane.pane_id`。 +接受 `--current` 的窗格命令在 Herdr 窗格内运行时使用调用方的 `HERDR_PANE_ID`。对 `pane split` 而言,显式窗格 ID 或 `--pane ID` 拆分指定窗格。省略目标时,若存在 `HERDR_PANE_ID` 则拆分调用方窗格,否则使用当前聚焦的窗格。`--current` 必须有调用方窗格;缺少 `HERDR_PANE_ID` 时会报错。拆分响应中的新窗格 ID 位于 `.result.pane.pane_id`。 执行 `pane move` 后,后续命令应使用 `.result.move_result.pane.pane_id`。跨工作区移动会改变带工作区前缀的窗格 ID;旧值保留在 `.result.move_result.previous_pane_id`。运行中进程的 `HERDR_PANE_ID`、`HERDR_TAB_ID` 和 `HERDR_WORKSPACE_ID` 保持启动时的值,但 Herdr 会把旧窗格 ID 保留为该终端的别名,所以使用 `--current` 的窗格命令仍能解析它。实时智能体名称也会跟随终端。 @@ -342,11 +342,17 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] ```bash herdr terminal attach <terminal_id> [--takeover] +herdr terminal session control <target> [--takeover] [--cols N] [--rows N] +herdr terminal session observe <target> [--cols N] [--rows N] herdr terminal title set <title> herdr terminal title clear ``` 从直接附加中用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 +`terminal session control` 为窗格、终端或智能体打开可写的实时终端流,输出与 observe 相同的逐行 JSON `terminal.frame` 和 `terminal.closed`,并从标准输入读取 `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` 命令。同一终端同时只能有一个控制方,可用 `--takeover` 接管。 + +`terminal session observe` 为相同目标打开只读实时流,输出包含 base64 ANSI 字节的逐行 JSON `terminal.frame`,服务器关闭流时输出 `terminal.closed`。多个观察方可以同时查看终端,不获取输入、调整尺寸、滚动或接管权限。Linux/macOS 上,若套接字写入连续 30 秒没有进展,观察方会被断开;此时可能没有最后的 `terminal.closed` 记录,但窗格和其他客户端继续运行。安静、没有输出的窗格不会触发此超时。 + `terminal title clear` 把外层终端窗口标题交还给 `ui.window_title`。 ## 输出等待 diff --git a/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx b/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx index 8180452b..4175d5ee 100644 --- a/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx @@ -17,7 +17,7 @@ ssh workbox `workbox` 可以是 SSH 配置中的主机名,也可以使用 `ssh://you@server:2222` 这样的目标。 -多机器连接支持 Linux 和 macOS 客户端,连接目标为 x86_64 或 aarch64 的 Linux 和 macOS 服务器。Windows 上的多机器连接尚未验证,也暂不受支持;独立连接命令 `herdr --remote` 仍支持 Windows。原生 Windows 服务器不能作为 SSH 目标。有关 SSH 配置、认证和自定义二进制文件,请参阅[通过 SSH 远程连接](/zh-cn/docs/persistence-remote/)。 +多机器连接支持 Linux、macOS 和 Windows 客户端,连接目标可以是 x86_64 或 aarch64 的 Linux/macOS 服务器,也可以是 x86_64 的 Windows 服务器。交互式设置可在确认后安装或更新完整的远程软件包;后台重连只查找已安装的软件包。有关 SSH 配置、认证和自定义二进制文件,请参阅[通过 SSH 远程连接](/zh-cn/docs/persistence-remote/)。 ## 添加机器 @@ -43,7 +43,13 @@ Herdr 分别检查已安装的二进制文件和运行中的服务器,并在 在侧边栏选择机器或其工作区。只有选中的机器接收窗格输入和终端尺寸,并提供可见的终端内容和图形。其他已连接机器仍会更新工作区信息、智能体状态和通知,但不传输窗格画面。 -启动时会立即打开 Local,不等待 SSH 连接。一台机器无响应不会阻塞其他机器的输入。多个 Herdr 客户端也可以独立查看同一服务器上的不同标签页;共享标签页的尺寸规则见[客户端与服务器](/zh-cn/docs/concepts/)。 +点击任意机器旁的箭头,可以折叠或展开其工作区列表,而不切换当前工作区。机器正在重连时也可以操作。 + +键盘导航先按 `prefix+w`,再使用工作区导航键(默认为 Up/Down),按侧边栏顺序高亮已连接机器上的工作区。Enter 确认切换;Esc 或前缀键取消,不切换机器。紧凑和展开的侧边栏都会显示高亮行,包括折叠机器内的行。桌面导航在两端循环,移动端切换器在首尾停止。已断开的机器会被跳过。 + +高亮其他机器的工作区后,请先按 Enter 再使用其他键盘操作,避免误对当前机器执行窗格、标签页、工作区或自定义命令。点击会取消桌面上的远程键盘导航预览。展开的侧边栏会保留每台机器的 worktree 分组折叠状态。 + +启动时会立即打开 Local,不等待 SSH 连接。选择 Local 也会立即取消尚未完成的远程切换,无需等待远程响应。如果 Local 自身正在重连,会在就绪后继续选中它。收到 Local 的新画面和终端设置后即可输入。一台机器无响应不会阻塞其他机器的输入。多个 Herdr 客户端也可以独立查看同一服务器上的不同标签页;共享标签页的尺寸规则见[客户端与服务器](/zh-cn/docs/concepts/)。 断线后,最后的工作区和智能体状态仍然可见,但会变暗。这是缓存,不是实时状态。在新连接和对应画面到达前,不能输入或导航到这些缓存窗格。重连不会从正在使用的机器抢走选择。 @@ -67,10 +73,12 @@ herdr machine remove <profile-id> ## 连接问题 -- **Reconnecting:** 网络中断、睡眠或 SSH 故障后,Herdr 会以有上限的退避间隔重试。SSH 连接会监测应用层活动并在空闲时探测,避免已断开的连接一直显示 Online。Local 使用本地连接关闭或失败来判断断线,而不是远程健康探测。 +- **Reconnecting:** 网络中断、睡眠或 SSH 故障后,Herdr 会自动重试。连续失败会将间隔增加到最多两分钟,短暂连接成功不会重置间隔。连接稳定一分钟后,下一次中断才会恢复快速重试。SSH 连接会监测应用层活动并在空闲时探测,避免已断开的连接一直显示 Online。Local 使用本地连接关闭或失败来判断断线,而不是远程健康探测。 - **Attention:** 目标需要无法在后台完成的操作,例如批准主机密钥、认证或准备兼容服务器。其他机器仍可使用。 - **保存机器文件错误:** 目录文件无法读取或内容无效时,当前连接保持不变。Herdr 会显示通知并自动重试读取。 +当两端都支持空闲桥接清理时,连接 Linux/macOS 的保存机器会在双向均无流量一分钟后关闭远程桥接。睡眠时间也计入期限,并在唤醒时检查。正常的空闲连接会交换健康检查,持续查看输出无需键盘输入。清理不会停止远程 Herdr 服务器、会话或窗格进程。旧安装仍可兼容连接,只是不使用这项可选清理功能。 + 后台连接不会回答提示,也不会安装、更新、重启或交接服务器。对于 Attention,请在交互式终端运行 Herdr 显示的独立设置命令,例如: ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index f6d7c1f3..5eb1f322 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -287,6 +287,8 @@ Herdr 用 `qwen --resume <id>` 恢复保存的 Qwen Code 会话。 ## Letta Code +Letta Code 集成安装是实验性的 CLI 专用功能,不在 Settings 的集成列表或 JSON 集成 API 中提供。`herdr integration status` 将其显示为 `letta (experimental)`。智能体检测和 `herdr agent start --kind letta` 不受此安装方式限制,均已支持。 + 安装 Letta Code 钩子: ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx index da795378..f1472966 100644 --- a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx +++ b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx @@ -62,6 +62,24 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智 完整键位和绑定语法见[按键绑定参考](/zh-cn/docs/configuration/#按键绑定)。 +## 编辑文本输入框 + +Herdr 的命名对话框、worktree 分支名、筛选框和复制模式搜索框支持在光标处编辑。以下快捷键适用于 Herdr 自己的输入框,不改变窗格内 shell 或智能体的按键行为。 + +| 操作 | 按键 | +| --- | --- | +| 移动一个字符 | Left / Right、Ctrl+B / Ctrl+F | +| 移到开头 / 末尾 | Home / End、Ctrl+A / Ctrl+E | +| 移动一个词 | Alt+B / Alt+F | +| 删除前一个字符 | Backspace、Ctrl+H | +| 删除后一个字符 | Delete、Ctrl+D | +| 剪切到开头 / 末尾 | Ctrl+U / Ctrl+K | +| 剪切前一个词 | Ctrl+W、Alt+Backspace、Ctrl+Backspace | +| 剪切后一个词 | Alt+D | +| 插入上次剪切的文本 | Ctrl+Y | + +输入和粘贴会插入光标所在位置。移动和删除遵循 Unicode 字素边界,长内容会水平滚动。剪切的文本只保留在当前输入框中,不使用系统剪贴板。Alt 快捷键需要终端报告 Alt/Meta。Enter 和 Esc 仍用于对话框的确认和取消。 + ## 复制模式 按 `prefix+[` 让聚焦的窗格进入复制模式。用 `h/j/k/l`、tmux 风格的 `w/b/e` 和 `{`/`}` 移动。按 `/` 或 `?` 向前或向后进行文本搜索,再用 `n` 或 `N` 按相同或相反方向重复搜索。查询包含大写字母时区分大小写,否则不区分。用 `v` 或空格开始选择,用 `y` 或回车复制,用 `q` 或 Esc 不复制直接退出。Esc 会先清除当前选择或搜索,然后才退出。复制模式不会暂停窗格进程:停留在底部时继续跟随输出,进入历史记录后保持当前位置。鼠标拖选可以直接复制,完全不用进入复制模式。 diff --git a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx index 65567bc3..ddd0b0fb 100644 --- a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -69,7 +69,7 @@ Host workbox herdr --remote workbox ``` -远程连接支持 Linux、macOS 和 Windows 本地客户端连接 x86_64 与 aarch64 的 Linux 和 macOS 主机。Herdr 会检查远程平台,优先使用远程 `PATH` 上兼容的 `herdr`,然后检查常见的直接安装、Homebrew、mise 和 Nix profile 路径。只要两端都支持稳定端点代数,客户端和服务器的发行版本就不必一致。如果没有兼容的二进制文件,交互式运行会询问是否安装到 `~/.local/bin/herdr`;非交互式运行则直接失败,不会修改主机。如果 `~/.local/bin` 不在远程 `PATH` 上,Herdr 会在安装后发出警告。Windows 不能作为远程主机。 +远程连接和已保存的 SSH 机器支持 Linux、macOS 和 Windows 客户端,目标可以是 x86_64 或 aarch64 的 Linux/macOS 主机,也可以是 x86_64 的 Windows 主机。只要两端支持稳定端点代数,版本就不必一致。Linux/macOS 上优先使用远程 `PATH` 中兼容的 `herdr`,再检查常见的直接安装、Homebrew、mise 和 Nix profile 路径。Windows 上检查 `PATH` 和当前有效的托管版本。如果没有兼容的二进制文件,交互式运行会询问是否安装或更新;非交互式运行直接失败,不修改主机。Linux/macOS 安装到 `~/.local/bin/herdr`,Windows 安装包含应用本地 ConPTY 运行时的完整软件包。如果 `~/.local/bin` 不在远程 `PATH` 中,Herdr 会发出警告。 默认情况下,`herdr --remote` 通过一个临时 SSH 配置运行远程设置和桥接: 先包含你的 SSH 配置,再补充兜底的保活设置。已有的用户保活设置优先。Linux 和 macOS 客户端还会添加一个私有的单次连接 control socket 来复用连接;Windows OpenSSH 不使用此方式。设置 `[remote].manage_ssh_config = false` 可以不用 Herdr 生成的配置或 control socket,而使用普通 `ssh`。 @@ -90,9 +90,9 @@ herdr --remote workbox --handoff 如果你先 SSH 到服务器再在那里运行 `herdr`,Herdr 就完全运行在服务器上。这种模式简单实用,但除了普通的终端文本粘贴之外,它无法访问你本地桌面的剪贴板。 -当本地和远程平台一致时,对于直接安装,Herdr 可以直接复制当前本地二进制文件。对于 Homebrew、mise 和 Nix 安装,或平台不一致时,它会从 `https://herdr.dev/latest.json` 下载与当前客户端版本匹配的发布产物。 +对于 Linux/macOS 主机,平台一致的直接安装可以复制当前本地二进制文件。Homebrew、mise、Nix 安装、平台不一致或目标为 Windows 时,会从 `https://herdr.dev/latest.json` 下载与当前客户端版本对应的发布产物。 -对于本地构建或自定义二进制文件,在远程连接前设置 `HERDR_REMOTE_BINARY` 指向本地文件路径。 +对于本地构建或自定义软件包,在远程连接前设置 `HERDR_REMOTE_BINARY` 指向本地文件。Linux/macOS 目标使用 Herdr 可执行文件,Windows 目标必须使用完整的 `herdr-windows-x86_64.zip` 软件包。 ```bash HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox diff --git a/docs/next/website/src/content/docs/zh-cn/quick-start.mdx b/docs/next/website/src/content/docs/zh-cn/quick-start.mdx index 3f695d92..87211e28 100644 --- a/docs/next/website/src/content/docs/zh-cn/quick-start.mdx +++ b/docs/next/website/src/content/docs/zh-cn/quick-start.mdx @@ -17,9 +17,9 @@ Herdr 会启动或连接到你的默认后台会话。你不需要管理 socket ## 使用鼠标 -Herdr 是鼠标原生的,所以从点击开始。点击窗格、标签页、工作区和智能体来聚焦它们。拖动分割边框来调整大小。右键打开上下文菜单,包括分割窗格和创建标签页。拖选文本即可复制到剪贴板;双击一个词直接复制它。复制不需要 Ctrl+C。 +Herdr 是鼠标原生的,所以从点击开始。点击窗格、标签页、工作区和智能体来聚焦它们。拖动分割边框来调整大小。右键打开上下文菜单,包括分割窗格和创建标签页。拖选文本即可复制到剪贴板。双击一个词可以选中它;按住第二次点击并拖动,会按整词扩展选择。两种操作都会在松开鼠标时复制。复制不需要 Ctrl+C。 -当终端把带修饰键的点击传给 Herdr 时,Ctrl+点击可以打开窗格内的链接。这对 OSC 8 超链接和可见的 `http://` 或 `https://` URL 有效。在 macOS 上,鼠标捕获开启时,请用 Ctrl+点击来触发 Herdr 处理的窗格链接;Cmd+点击只能通过终端原生的绕过路径使用,比如 Shift+Cmd+点击或 `ui.mouse_capture = false`。 +当终端把带修饰键的点击传给 Herdr 时,Ctrl+点击可以打开窗格内的链接。这适用于 OSC 8 超链接及 `http://` 或 `https://` URL,包括首尾部分位于可见区域之外的换行链接。按住 Ctrl 并将指针移到链接上,会为可见部分添加下划线。移开指针、窗格内容变化或收到 Ctrl 释放事件时,高亮会清除;部分终端只在鼠标移动时报告修饰键变化。在 macOS 上,鼠标捕获开启时,请用 Ctrl+点击来触发 Herdr 处理的窗格链接;Cmd+点击只能通过终端原生的绕过路径使用,比如 Shift+Cmd+点击或 `ui.mouse_capture = false`。 如果配置了 `ui.right_click_passthrough_modifier`,该修饰键加右键会把右键点击、按住和拖动手势发送给启用鼠标上报的窗格应用。 diff --git a/docs/next/website/src/content/docs/zh-cn/session-state.mdx b/docs/next/website/src/content/docs/zh-cn/session-state.mdx index 9747e2b6..c4c4a387 100644 --- a/docs/next/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/next/website/src/content/docs/zh-cn/session-state.mdx @@ -75,6 +75,7 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 | Claude Code | `6` | `claude --resume <id>` | | Codex | `5` | `codex resume <id>` | | Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | +| Grok CLI | `2` | `grok --resume <id>` | | GitHub Copilot CLI | `2` | `copilot --resume=<id>` | | Devin CLI | `2` | `devin --resume <id>` | | Droid | `2` | `droid --resume <id>` | @@ -101,6 +102,8 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 交接保护的是由服务器拥有的长期会话状态:窗格 PTY 和进程、智能体身份与持久元数据,以及替换服务器所需的插件/会话状态。它不会跨替换边界保留临时协调状态。进行中的 CLI 或 API 请求、等待、订阅流、客户端套接字和窗格间消息可能会中断;客户端应重新连接并重试。 +更新后的 Unix 服务器可以分批传输,在一次交接中发送超过 64 个窗格。发送方服务器必须已经支持此功能;从旧服务器进行第一次升级时,仍受其原有的 64 窗格限制。此时可能需要正常重启服务器,这会结束窗格进程。分批传输不会改变更新的安装顺序,也不会取消其他交接兼容性要求。 + 实时交接是实验性功能,需要主动开启: ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index 9a2f5c4e..43400eaa 100644 --- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx @@ -345,7 +345,7 @@ UI 上下文变化时,该投影会重新求值。它控制展开和折叠的侧 过滤字段是 `status`、`workspace_id`、`tab_id`、`pane_id`、`agent`、`seen` 和 `state_change_seq`。要过滤插件上报的窗格元数据,使用 `{"token":"name"}` 作为字段。值可以是字符串、布尔值、无符号整数或上下文对象。上下文值包括 -`current_workspace_id` 和 `current_tab_id`,并且只能与对应的 ID 字段比较。 +`current_workspace_id` 和 `current_tab_id`,并且只能与对应的 ID 字段比较。在保存机器客户端中,所选服务器的视图应用于合并后的智能体列表。当前工作区和标签页上下文也包含所选机器,因此其他机器上的相同 ID 不会匹配;而 `status` 为 `blocked` 等独立条件仍可匹配任意已连接机器上的智能体。 有效 status 值为 `idle`、`working`、`blocked`、`done` 和 `unknown`;`done` 表示 idle 且尚未查看。 @@ -637,7 +637,7 @@ workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏 工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。`workspace.reordered` 包含原子移动的 `workspace_ids`、可选的 `before_workspace_id` 和服务器确认的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。 标签页事件订阅包括 `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed` 和 `tab.moved`。`tab.moved` 包含被移动的 `tab_id`、`workspace_id`、请求的 `insert_index` 和该工作区更新后的有序 `tabs` 列表。 -窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。 +窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。`pane.focused` 也会上报任意连接到此服务器的客户端手动选择窗格的变化,不会移动其他客户端的视图,且载荷不包含客户端身份。重复选择已选中的窗格不会再次发出事件。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。 布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 引导的客户端应替换具有相同 `workspace_id` 和 `tab_id` 的缓存布局。 Worktree 事件订阅包括 `worktree.created`、`worktree.opened` 和 `worktree.removed`。Worktree 事件描述 Git 检出的生命周期。`worktree.created` 包含打开的 `workspace` 和创建的 `worktree`。`worktree.opened` 包含目标 `workspace`、打开的 `worktree` 和 `already_open`。`worktree.removed` 包含 `workspace_id`、被移除的 `worktree` 和 `forced`。 diff --git a/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx b/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx index 35b1ff33..025f0f4d 100644 --- a/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx +++ b/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx @@ -32,7 +32,7 @@ Windows 构建同时通过稳定版和预览版更新通道提供。新安装默 | 本地持久会话 | 已支持 | | 通过 ConPTY 的原生窗格 | 已支持 | | Windows Terminal / PowerShell 应用连接 | 已支持 | -| 连接 Linux/macOS 主机的 `herdr --remote` | 已支持 | +| 连接 Linux/macOS/Windows 主机的 `herdr --remote` 和已保存 SSH 机器 | 已支持;交互式连接和机器设置可在确认后安装或更新 Windows 软件包,后台重连只查找已安装的软件包 | | 远程剪贴板图像和图像文件拖放 | 已支持 | | `cmd.exe` 窗格 | 已支持 | | 原生键盘和鼠标输入 | 已支持 | @@ -80,7 +80,7 @@ switch_ascii_input_source_in_prefix = true 此选项暂不支持其他 Windows 输入法。参见[配置](/zh-cn/docs/configuration/#前缀输入源切换)。 -Windows 上的部分智能体可以接收 `ctrl+v` 并直接读取剪贴板图像。Herdr 自己的剪贴板图像读取器尚未接入本地原生 Windows 窗格,因此智能体原生图像粘贴仍取决于终端和智能体。`alt+v` 等智能体图像粘贴快捷键不会增加由 Herdr 管理的本地剪贴板桥。远程剪贴板图像桥通过连接 Linux/macOS 主机的 `herdr --remote` 单独受支持。 +Windows 上的部分智能体可以接收 `ctrl+v` 并直接读取剪贴板图像。Herdr 自己的剪贴板图像读取器尚未接入本地原生 Windows 窗格,因此智能体原生图像粘贴仍取决于终端和智能体。`alt+v` 等智能体图像粘贴快捷键不会增加由 Herdr 管理的本地剪贴板桥。远程剪贴板图像桥通过连接 Linux/macOS/Windows 主机的 `herdr --remote` 单独受支持。 Kitty graphics 默认启用,并取决于外层终端。Herdr 会像在其他平台上一样在 Windows 上输出 Kitty graphics protocol。这个路径已在 Windows WezTerm 托管 WSL 中 Herdr 的配置下得到验证,但原生 Windows 的各种终端与 ConPTY 组合尚未全部验证。Windows Terminal 不提供 Herdr 使用的 Kitty graphics 路径。如果外层终端无法正确处理图形输出,请设置 `[terminal].kitty_graphics = false`。 @@ -118,7 +118,6 @@ Herdr 的窗格文本复制在 Windows 上可用。在窗格内拖选文本即 | 能力 | 状态 | | --- | --- | | 直接终端附加 (`herdr terminal attach`) | 不支持 | -| Windows 作为 `herdr --remote` 的目标主机 | 不支持 | | 实时服务器交接 | 不支持 | | Unix 文件描述符交接 | 不支持 | | Unix 前台进程组 | 不支持 | @@ -131,7 +130,7 @@ Herdr 的窗格文本复制在 Windows 上可用。在窗格内拖选文本即 herdr --remote workbox ``` -目标主机必须运行 Linux 或 macOS。Herdr 使用已安装的 Windows OpenSSH 客户端和你的 SSH 配置。Windows OpenSSH 不使用 Herdr 的 Unix control socket 连接复用,因此建议通过 Windows `ssh-agent` 使用密钥认证,避免远程设置期间重复提示。 +目标主机可以运行 Linux、macOS 或 Windows。Windows 主机会复用 `PATH` 或当前有效的托管版本中的兼容软件包。交互式连接和保存机器设置会在安装或更新完整软件包前询问;后台重连只查找已安装的软件包,不会提示安装或更新。Herdr 使用已安装的 Windows OpenSSH 客户端和你的 SSH 配置。Windows OpenSSH 不使用 Herdr 的 Unix control socket 连接复用,因此建议通过 Windows `ssh-agent` 使用密钥认证,避免远程设置期间重复提示。 Windows 更新通过 Windows 安装器进行,并更新当前带版本号的发布路径。新终端和重新连接的 SSH 会话会获得该路径;在其中启动 Herdr 即可使用更新后的客户端。兼容的运行中服务器会让窗格继续运行。只有需要新版本的服务器端改动时,才稍后重启服务器。实时交接仅限 Unix。 diff --git a/docs/versions/0.9.1/website/src/content/docs/agent-automation.mdx b/docs/versions/0.9.1/website/src/content/docs/agent-automation.mdx new file mode 100644 index 00000000..e7c02ae3 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/agent-automation.mdx @@ -0,0 +1,125 @@ +--- +title: Agent automation +description: Use Herdr's layout, pane, and agent primitives to coordinate coding agents from scripts or other agents. +--- + +Use Herdr as an automation layer for coding agents. A script can control them, or one agent can create work for other agents, inspect their state, and collect their results. Choose the primitive that matches the job. + +## Three primitives + +| Primitive | Responsibility | +| --- | --- | +| Layout (`workspace`, `tab`, and pane topology) | Create and organize terminal locations. | +| Pane | Control a raw terminal: run commands, send input, read output, and wait for output. | +| Agent | Control a recognized coding agent by name or pane and lifecycle state. | + +A pane exists whether or not it contains an agent. An agent is the recognized process currently running inside a pane. `agent start` therefore requires an existing shell pane and never creates, splits, or moves layout. + +Creating a workspace also creates its first tab and root pane; creating a tab creates its root pane. Use the returned pane ID for the first process, and split only when that layout needs another terminal. + +Creation commands print JSON. Capture IDs from the response instead of predicting them: + +```bash +created=$(herdr workspace create --cwd ~/project --label api --no-focus) +pane_id=$(printf '%s\n' "$created" | jq -r '.result.root_pane.pane_id') + +split=$(herdr pane split "$pane_id" --direction right --no-focus) +review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') +``` + +`workspace create` returns `.result.workspace`, `.result.tab`, and `.result.root_pane`. `tab create` returns `.result.tab` and `.result.root_pane`. `pane split` returns the new pane as `.result.pane`. + +Moving a pane to another workspace changes its workspace-qualified pane ID. After any `pane move`, continue with `.result.move_result.pane.pane_id`; the response keeps the old value at `.result.move_result.previous_pane_id`. A running process keeps its launch-time Herdr environment, but the old `HERDR_PANE_ID` remains an alias for that terminal, so `--current` stays safe. New commands can still resolve the agent by name after the move, but a wait already in progress ends with `agent_not_running`. + +Use pane commands for shells, tests, servers, CI watchers, and other ordinary terminal processes. Use agent commands when Herdr needs to understand which agent is running or whether it is `working`, `blocked`, `done`, `idle`, or `unknown`. + +## Agent identity and launch + +A pane ID such as `w1:p2` identifies the terminal location. An agent name such as `reviewer` is a convenient alias for the current agent in that pane. Names must match `[a-z][a-z0-9_-]{0,31}` and be unique among live agents. The alias is cleared when that agent exits, is released, or is replaced; it does not permanently rename the pane. + +Agent commands accept either a unique live name or the pane ID that currently hosts the agent. + +An available shell pane is at its interactive shell prompt: the shell itself owns the foreground, with no foreground command, editor, or agent running. Return the pane to its prompt before calling `agent start`. + +`--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, `maki`, and `muse`. Arguments after `--` are passed unchanged to that executable. + +Successful `agent start` returns only after Herdr detects the expected agent in the same terminal and marks it ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. Startup waits for 30 seconds by default; `--timeout` must be greater than 3000 and no more than 300000 milliseconds. + +```bash +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +``` + +Agents launched manually are detected automatically and can be addressed by pane ID. Give one a name when a stable human-readable target is useful: + +```bash +herdr agent get w1:p2 +herdr agent rename w1:p2 reviewer +``` + +## Choose the control surface + +| Goal | Command | +| --- | --- | +| Run a shell command and submit it | `pane run` | +| Send literal text without Enter | `pane send-text` | +| Send terminal keys or modifier chords | `pane send-keys` | +| Wait for text or a regular expression | `pane wait-output` | +| Start a supported agent in an existing pane | `agent start` | +| Submit a prompt, optionally waiting for it | `agent prompt` | +| Send keys to an agent's interactive UI | `agent send-keys` | +| Wait for agent lifecycle state | `agent wait` | + +`agent prompt` submits text plus encoded Enter and honors the terminal's live bracketed-paste mode. It can prompt an agent that is already working. If the agent is already `blocked`, it returns `agent_blocked` without sending terminal input; inspect the dialog and use `agent send-keys` for a deliberate response. Use `agent send-keys` for interactions such as `esc`, `up`, `enter`, or `ctrl+c`; `escape` is accepted as an alias for `esc`. Use the pane input commands when you deliberately want raw terminal control. + +Pane input addresses the terminal regardless of its current occupant. Agent input resolves the live agent and rejects the operation if that agent no longer controls the pane. + +`agent prompt --wait` rejects an agent already `blocked` with `agent_blocked`, without sending input or starting a wait. Otherwise, it writes the prompt and delayed Enter as one ordered submission before waiting. On Windows, Codex receives a paste boundary before Enter so submission does not depend on prompt size. The caller timeout includes submission time. If the prompt started from another non-working state, Herdr waits up to five seconds after submission to observe `working` or `blocked`. Otherwise, it returns `agent_prompt_stalled`; if the caller timeout expires first, it returns the normal `timeout` error. This prevents unrelated `idle`, `done`, or session changes from completing the wait. After Herdr observes activity, it waits for the requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. Standalone `agent wait` observes the current agent and returns immediately if its status already matches. Both commands default to `idle`, `done`, or `blocked`. Repeat `--until` to accept several exact states, for example `--until idle --until done`; use `--until unknown` explicitly when needed. On `agent prompt`, `--until` requires `--wait`. + +`idle` and `done` both mean the agent is ready for input. The CLI/API uses the server's seen state: `done` is idle but not yet marked seen, explicit `pane focus` / `agent focus` commands mark the target seen, and reads do not. Each TUI client tracks viewed completions independently, so a client's Done badge can differ from the CLI or another client's badge. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify its lifecycle confidently; it does not prove successful completion. Use exact `--until` states when that distinction matters. + +A timeout or `agent_prompt_stalled` does not prove that no input was sent. Read the agent before retrying to avoid submitting the same prompt twice. IDs and agent names are scoped to one server; selecting another machine in the TUI does not retarget CLI commands running in an existing pane. + +`pane wait-output` does not interpret agent lifecycle. It polls the selected terminal snapshot and searches it immediately, so text that was already present can match. The default source name is `recent`; matching treats that source as unwrapped recent output from the latest 80 rendered terminal rows. `--lines` changes that row limit, and `--regex` uses Rust regular-expression syntax and matches one line at a time. + +At the CLI, both `pane read` and `agent read` print terminal text directly. The default is UTF-8 text with ANSI escapes stripped; use `--format ansi` or `--ansi` to preserve terminal escapes where the source exposes them. The `detection` source is always plain text. For recent sources, `--lines N` selects the last N rendered terminal rows before optional unwrapping; without it, reads default to 80 rows. For `visible` and `detection`, omitting `--lines` returns the full snapshot, while specifying it keeps the last N newline-delimited lines. The socket API returns the text at `.result.read.text`. + +## Alternate-screen history reads + +Full-screen agents such as Claude Code and OpenCode render transcript history in the terminal's alternate screen instead of Herdr's host scrollback. For an idle, recognized agent at the bottom of its transcript, text reads from `recent` or `recent-unwrapped` automatically use the agent's mouse-scroll interface when `--lines` requests more than the visible screen. Herdr collects overlapping pages and returns the viewport to the bottom before completing the read. The same behavior applies to `pane read` when the pane contains that agent; it requires no additional option. + +Other reads remain passive. Herdr does not move the application viewport for `visible`, `detection`, or ANSI reads, output waits and subscriptions, a manually scrolled agent, a direct attachment, or an application that does not report mouse-wheel input. An explicit `agent read --lines N` that needs alternate-screen history returns `agent_not_idle` while the agent is working, blocked, or unknown; wait for idle and retry, or use `--source visible`. Other recent reads return the available screen and host scrollback as before. + +If a full response is still unavailable, ask the agent to write it as Markdown in a temporary directory and reply only with the file path, then read the file directly. + +Successful `agent start`, `agent prompt`, and `agent wait` commands return the current agent at `.result.agent`. `pane wait-output` returns `.result.pane_id`, `.result.matched_line`, and the matched snapshot at `.result.read`. + +Wait commands have no default timeout and can wait indefinitely. On timeout or another server error, CLI commands print a JSON error to stderr and exit with status 1; invalid CLI syntax exits with status 2. + +## Recipes + +Start a helper, give it work, and wait for that work to settle: + +```bash +split=$(herdr pane split --current --direction right --no-focus) +review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +herdr agent prompt reviewer "Review the current diff" --wait --timeout 120000 +herdr agent read reviewer --source recent-unwrapped --lines 120 +``` + +Wait for an agent to ask for input, inspect it, and interact with its UI: + +```bash +herdr agent wait reviewer --until blocked --timeout 120000 +herdr agent read reviewer --source recent-unwrapped --lines 80 +herdr agent send-keys reviewer esc +``` + +Run an ordinary process and wait for its output without treating it as an agent: + +```bash +herdr pane run w1:p3 "just test --watch" +herdr pane wait-output w1:p3 --regex "passed|failed" --timeout 120000 +``` + +See the [CLI reference](/docs/cli-reference/) for the complete command and option list. Shell completions expose the same command tree interactively. diff --git a/docs/versions/0.9.1/website/src/content/docs/agent-skill.mdx b/docs/versions/0.9.1/website/src/content/docs/agent-skill.mdx new file mode 100644 index 00000000..d73832ac --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/agent-skill.mdx @@ -0,0 +1,67 @@ +--- +title: Agent skill file +description: Install Herdr instructions for Claude Code or another coding agent. +--- + +Herdr ships a reusable agent skill file at [`skills/herdr/SKILL.md`](https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md). + +Install that file into any coding agent that supports reusable skills or custom instructions. The skill teaches the agent how to control Herdr from inside a Herdr pane. + +Herdr also serves a separate guide at [`herdr.dev/agent-guide.md`](https://herdr.dev/agent-guide.md) for a different job: an agent helping a human learn, set up, or troubleshoot Herdr. The skill is for an agent operating Herdr; the guide is for an agent teaching a human. + +## What the skill does + +The skill tells an agent to use the `herdr` CLI when `HERDR_ENV=1` is set. That means the agent is running inside a Herdr-managed pane and can safely talk to the local Herdr socket. + +With the skill installed, an agent can: + +- inspect workspaces, tabs, panes, and neighboring agents +- split panes and run commands without stealing focus +- read pane output and recent logs +- wait for servers, tests, or another agent to finish +- start helper agents in sibling panes + +The skill is a Markdown instruction file for agents. If Herdr is already installed, run `herdr --skill` to print the release-matched copy bundled with that binary. + +## Install it + +Install the skill with `npx skills`: + +```bash +npx skills add herdrdev/herdr --skill herdr -g +``` + +If you installed the skill while it lived at the repository root, rerun this add command once instead of using `skills update`. The add command replaces the oversized copy and records the new location. + +The `-g` flag installs it globally for supported agents. Omit `-g` to install it into the current project. + +Use the repository copy as the manual fallback and source of truth: + +```text +https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md +``` + +For agents with a skill system, install that file as a skill named `herdr`. For agents without a skill system, paste the file into the agent's project or user instructions. + +After installation, start the agent inside Herdr: + +```bash +herdr +claude +``` + +Or use any other coding agent in a Herdr pane so `HERDR_ENV=1` is available to the agent process. + +## Safety rule + +The skill starts with one guardrail: if `HERDR_ENV=1` is not set, the agent should stop and say it is not running inside a Herdr-managed pane. + +This prevents an agent outside Herdr from trying to control a session it does not own. + +## Agent-facing reference + +The full command guide lives in the skill file itself. It covers pane IDs, `pane split`, `pane run`, `pane read`, `pane wait-output`, `agent wait`, workspace and tab commands, and coordination recipes. + +Read the source file here: + +[Open `SKILL.md` on GitHub →](https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md) diff --git a/docs/versions/0.9.1/website/src/content/docs/agents.mdx b/docs/versions/0.9.1/website/src/content/docs/agents.mdx new file mode 100644 index 00000000..d0612c3f --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/agents.mdx @@ -0,0 +1,159 @@ +--- +title: Agents +description: See what Herdr can detect, how agent state works, and how integrations improve it. +--- + +Herdr is built for running more than one coding agent at a time. Each agent stays in a real terminal pane with its shell, logs, prompts, and running processes intact. Herdr tracks which panes contain agents, rolls their state up to tabs and workspaces, and lets you jump straight to the pane that needs attention instead of polling every terminal by hand. + +To coordinate agents from scripts or from another agent, see [Agent automation](/docs/agent-automation/). + +## Supported agents + +Automatic detection works out of the box for common coding agents. The table shows which signal determines `idle`, `working`, and `blocked` for each one. + +| Agent | State authority | Integration role | +| --- | --- | --- | +| Pi | lifecycle hooks when installed; otherwise screen manifest | state and session | +| OMP | lifecycle hooks when installed | state and session | +| GitHub Copilot CLI | screen manifest | session | +| Devin CLI | screen manifest | session | +| Kimi Code CLI | lifecycle hooks when installed; otherwise screen manifest | state and session | +| Hermes Agent | screen manifest | session | +| Qoder CLI | screen manifest | session | +| Qwen Code | screen manifest | session | +| Letta Code | screen manifest | session | +| Droid | screen manifest | session | +| OpenCode | lifecycle plugin when installed; otherwise screen manifest | state and session | +| Kilo Code CLI | lifecycle plugin when installed; otherwise screen manifest | state and session | +| MastraCode | lifecycle hooks when installed | state and session | +| Claude Code | screen manifest | session | +| Codex | screen manifest | session | +| Cursor Agent CLI | screen manifest | session | +| Amp | screen manifest | none | +| Grok CLI | screen manifest | session | +| Antigravity CLI | screen manifest | session | +| Kiro CLI | screen manifest | none | +| Maki | screen manifest | none | +| Muse | screen manifest | none | + +Detected but less thoroughly tested: Gemini CLI and Cline. Unsupported agents still run normally as terminal processes. They just may not get rich state unless you add an integration or report state over the socket API. + +## Status authority + +Herdr first detects the foreground process in each pane. After that, each pane has one status authority. + +For agents with complete lifecycle hooks, the integration is authoritative when it is installed and actively reporting for the running pane. Herdr uses those hook reports for `idle`, `working`, `blocked`, and session identity. It does not also run screen manifest fallback for that same lifecycle authority. This avoids two competing sources of truth. + +For agents without complete lifecycle hooks, Herdr identifies the foreground process and reads the live bottom-buffer screen snapshot. It evaluates TOML manifests against that snapshot to classify `idle`, `working`, and `blocked`. For agents that emit them, manifests can also match terminal title and progress (OSC) sequences as detection evidence; when that evidence is absent, screen rules carry detection on their own. + +The screen snapshot comes from the recent bottom of the pane buffer, not the scrolled viewport. If you scroll back in Herdr, detection still follows the live agent UI at the bottom. + +Integrations marked `session` in the table above are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses screen manifest detection. + +## VMs and sandbox wrappers + +On Linux and macOS, a host-visible wrapper can hide the real agent process from Herdr. Set `HERDR_AGENT=<agent>` on the wrapper command to tell Herdr which existing agent screen manifest to use. For example, run `HERDR_AGENT=claude fence -- claude` on Linux or `HERDR_AGENT=claude nono run --profile claude-code -- claude` on macOS. The hint applies only to that foreground process. Herdr cannot see it if you set it only inside a VM or container. Avoid exporting it globally unless every inherited foreground process should be treated as that agent. + +Some restricted Linux runtimes do not expose a terminal foreground process group. Start the Herdr server with `HERDR_PROCESS_DETECTION=child-groups` to opt into direct child-process-group inference when native detection is unavailable. Native detection remains preferred, and the default `native` mode never performs this inference. The opt-in mode is best effort: a newer background job can be mistaken for the foreground job. The variable is read by the server and requires a restart; set it in the remote server environment rather than on an attaching client. + +## Blocked state + +Blocked detection is deliberately strict for screen-manifest agents. Herdr only marks `blocked` when the live bottom-buffer snapshot matches known visible approval, question, or permission UI. If no manifest rule matches for a known agent, Herdr falls back to `idle` and labels that fallback as `default_known_agent_idle_fallback` in explain output. + +This means unusual new agent prompts may initially show as `idle` instead of `blocked` until Herdr learns that screen shape. The misclassification affects only the visible status and waits. It should not make Herdr send input or take destructive action. + +## Detection manifests + +Bundled manifests live inside Herdr. Herdr also checks herdr.dev for remote manifest updates and applies valid per-agent rule updates automatically without requiring a Herdr restart. Remote manifests are stored in Herdr's state directory. Set `[update] manifest_check = false` to disable background remote manifest checks. + +Local overrides can replace a remote or bundled manifest from the platform config directory: + +```text +~/.config/herdr/agent-detection/<agent>.toml +``` + +Local overrides always win. Without a local override, Herdr uses the newer compatible manifest between the cached remote manifest and the bundled manifest in the running binary. On debug builds, the same config helper may use a development directory such as `herdr-dev`. Invalid override files are ignored with a warning and Herdr falls back to the cached remote or bundled manifest for that agent. + +Remote manifests patch detection rules for agents Herdr already knows how to identify. Adding a completely new agent still requires a Herdr binary update for process detection, labels, and integration behavior. + +The running server loads active manifests into memory on startup. Automatic remote manifest updates reload that in-memory cache after new rules are written. Run `herdr server update-agent-manifests` to fetch remote manifest updates immediately and reload the running server. After editing a local override manually, restart Herdr or run `herdr server reload-agent-manifests` to apply the file to the running server. + +Use `herdr agent explain` when a pane shows the wrong state: + +```bash +herdr agent explain <target> +herdr agent explain --file screen.txt --agent codex --json +``` + +Live explain is evaluated by the running server, so it reflects the active manifest cache. The explain output shows the agent, final state, whether screen detection was skipped by a full lifecycle authority, manifest source and version, cached remote version, local override shadowing, remote update status, matched rule, visible evidence flags, matcher and region evidence for evaluated rules, skipped-update reason for transcript viewers, and the idle fallback reason when no rule matched. + +Herdr can run inside tmux as the outer terminal environment. Agent detection does not inspect tmux sessions launched inside a Herdr pane. If a shell framework auto-enters tmux inside Herdr, Herdr sees `tmux` as the pane process instead of the agent behind it. + +## State rollups + +The sidebar rolls state upward. + +A blocked agent makes its pane, tab, and workspace look blocked. A working agent makes the workspace look active. A done agent stays visible until you view it. + +This is the main Herdr workflow: start several agents, let them work in parallel, and use the sidebar to see which project needs a decision, which one is still running, and which one is ready to review. + +## Direct integrations + +Install the integration for each agent you use to give Herdr hook or plugin reports instead of screen detection alone: + +```bash +herdr integration install claude +herdr integration status +``` + +Each supported agent has its own integration name and behavior. See [Integrations](/docs/integrations/) for the per-agent details and the full install list. If you are building an agent, the [custom integration guide](/docs/integrations/#integrate-your-own-agent) shows how to report lifecycle state without adding native support to Herdr. + +## Custom agent labels + +You can rename an agent target for display: + +```bash +herdr agent rename w1:p1 reviewer +herdr agent rename reviewer --clear +``` + +Targets accept a unique live agent name or the pane ID that currently hosts the agent. Terminal IDs and bare agent-kind labels are not accepted. + +## Custom status labels + +Integrations report lifecycle state as semantic state only. Add display customization separately with pane metadata tokens. + +```bash +herdr pane report-agent w1:p1 \ + --source custom:indexer \ + --agent docs-bot \ + --state working + +herdr pane report-metadata w1:p1 \ + --source custom:indexer-display \ + --token summary=indexing +``` + +`state` controls waits, notifications, and rollups. The `summary` token is display-only and can be used as `$summary` in an Agent sidebar row. + +Agent sidebar rows can also opt into `terminal_title` or `terminal_title_stripped`; neither appears in the default rows. The first shows the latest safety-normalized OSC 0/2 terminal title. The second removes one recognized leading activity or spinner glyph and following whitespace. Herdr owns these values on the server; they are ephemeral across a cold restart and remain independent of metadata titles and semantic agent state. Spinner animation can therefore update the raw title without producing a pane update when the stripped text stays the same. + +## Attach directly to an agent + +Attach your current terminal to one agent terminal instead of the full Herdr UI: + +```bash +herdr agent attach reviewer +``` + +Detach with `ctrl+b q`. Send a literal `ctrl+b` with `ctrl+b ctrl+b`. + +Scroll with the mouse wheel or plain page up/page down. Normal input jumps back to the bottom. + +Use `--takeover` if another direct attach client already owns input: + +```bash +herdr agent attach reviewer --takeover +``` + +Use `herdr terminal attach <terminal_id>` when you want the same direct attach behavior for a non-agent terminal. diff --git a/docs/versions/0.9.1/website/src/content/docs/cli-reference.mdx b/docs/versions/0.9.1/website/src/content/docs/cli-reference.mdx new file mode 100644 index 00000000..9f61d02d --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/cli-reference.mdx @@ -0,0 +1,544 @@ +--- +title: CLI reference +description: Herdr commands for sessions, workspaces, tabs, panes, notifications, agents, waits, integrations, and status. +--- + +Herdr’s CLI talks to the running server over the same local socket API used by integrations and agents. + +Most commands print JSON responses for deterministic automation in scripts. + +## Launch and status + +```bash +herdr # launch or attach to the default session +herdr --remote workbox # attach through SSH, using local keybindings +herdr --default-config # print default config +herdr update # download and install from the configured channel +herdr completion zsh # generate a zsh completion script +herdr channel show # print stable or preview +herdr channel set preview # opt into preview builds +herdr channel set stable # return a direct install to stable +herdr --version # print version +``` + +Optional launch and update settings: + +- Add `--session <name>` to use a named session instead of the default. +- Add `--remote-keybindings server` to remote attach to use server keybindings instead of local ones. +- Experimental live handoff requires explicit `--handoff` on `herdr --remote` or `herdr update`. It is not the normal setup or connection path; see [Updates](/docs/install/#update). + +Status commands: + +```bash +herdr status +herdr status server +herdr status client +``` + +API schema commands: + +```bash +herdr api schema +herdr api schema --json +herdr api schema --output herdr-api.schema.json +``` + +`herdr api schema` prints a short summary of the socket protocol schema bundled +with the installed binary. Use `--json` for the full JSON Schema document, or +`--output PATH` to write that document to a file. + +## Saved SSH machines + +```bash +herdr machine list +herdr machine add workbox --label "Build machine" +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +By default, `machine add` uses the remote default session. Add `--remote-session <name>` only for a named session. `machine list` accepts optional `--json` for scripts. See [Connecting machines](/docs/connecting-machines/) for the full setup and connection guide. + +`machine add` checks the remote installation's capabilities, installs or updates with approval only when needed, and starts the requested session's background server before saving. Compatible release versions do not need to match. Run setup in an interactive terminal when installation or restart approval is required; failed or cancelled setup does not save a profile. Replacing a running server requires explicit approval with a default answer of No, and stops its pane processes. `machine add` never enables experimental handoff implicitly. + +Changes apply automatically to open local Herdr clients, normally within a second. Added or enabled machines connect in the background; renaming does not reconnect. Removing or disabling disconnects only that machine and leaves its remote sessions running. Removing the machine you are viewing returns to Local, or shows Local as unavailable until it reconnects. Each profile stores an opaque ID, label, SSH target, explicit remote session, and enabled state in client state. Herdr does not store passwords, private keys, or other SSH credentials. + +Automatic connections and reconnects are non-interactive. If a host key, password, key passphrase, MFA step, install, update, or restart needs approval, the machine shows Attention instead of opening a hidden prompt. Run the standalone command printed by Herdr, such as `herdr --remote workbox`, to complete that setup in the foreground, then restart the client. Include `--session <name>` only when the profile targets a named session. + +Workspace, tab, pane IDs, and agent names belong to a single server. Selecting a machine in the UI does not retarget CLI commands. Use the global **prefix** `--machine <label-or-id>` to route API commands to a saved SSH machine: + +```bash +herdr --machine "Build machine" agent list +herdr --machine <profile-id> pane list +herdr --machine "Build machine" agent prompt w1:p1 "review this change" +herdr --machine "Build machine" worktree create --cwd /srv/project --branch review +``` + +The selector must match an enabled saved profile ID or a unique, case-sensitive label, not an arbitrary SSH hostname. The saved remote session is used; combining `--machine` with `--session` or `--remote` is an error. Without the prefix, commands retain their existing local session/socket behavior. + +Supported commands are `workspace`, `worktree`, `tab`, `pane`, `notification`, `agent` (except `attach` and local `explain --file`), `api snapshot`, `status server`, and API-backed plugin commands (`link`, `unlink`, `enable`, `disable`, `list`, `action`, `log`, `pane`). Server commands support `stop`, `reload-config`, `agent-manifests`, and `reload-agent-manifests`. Local installation/configuration commands, plugin installation, session management, and interactive terminal attachment are not forwarded. + +Requests and responses travel through the JSON API over non-interactive SSH; API payloads are not interpolated into the SSH shell command. No open TUI is required, and the bridge never installs, starts, or restarts a server. Update the local CLI and remote Herdr installation to support machine forwarding, and keep the running remote server's API protocol compatible. Authentication, unavailable machines, and incompatible versions fail without falling back to Local. Requests are not automatically retried after connection failure. + +Local pane IDs are not inherited by remote commands. Use explicit remote IDs; `--current` cannot refer to the caller's local pane. Remote worktree paths must be absolute, `~`, or start with `~/` (expanded on the server); plugin link paths must be absolute. The prefix targets one machine at a time; combined listings and routing through another TUI's connections are not included. + +## Shell completions + +```bash +herdr completion zsh +herdr completions zsh +herdr completion bash +herdr completion fish +herdr completion powershell +herdr completion elvish +``` + +`completion` prints the script to stdout. `completions` is an alias. For a +temporary zsh session, load the script directly: + +```bash +source <(herdr completion zsh) +``` + +For a persistent zsh setup, write the generated `_herdr` function somewhere on +your `fpath` before `compinit` runs: + +```bash +mkdir -p ~/.zfunc +herdr completion zsh > ~/.zfunc/_herdr +``` + +Then make sure your `.zshrc` contains: + +```zsh +fpath=(~/.zfunc $fpath) +autoload -Uz compinit +compinit +``` + +## Server + +```bash +herdr server +herdr server stop +herdr server reload-config +herdr server agent-manifests [--json] +herdr server update-agent-manifests [--json] +herdr server reload-agent-manifests +``` + +`herdr server` runs the headless server explicitly. Use it for supervised or service-style setups. `reload-config` applies reloadable settings without restarting panes. `agent-manifests` shows the active agent detection manifest sources, cached remote versions, and last remote update results. `update-agent-manifests` fetches remote manifest updates immediately, reloads them into the running server, and prints the updated manifest status; pass `--json` for the raw status response. `reload-agent-manifests` reloads agent detection manifests into the running server after local override edits. + +## Notifications + +```bash +herdr notification show <title> [--body TEXT] [--position top-left|top-right|bottom-left|bottom-right] [--sound none|done|request] +``` + +`notification show` uses the configured `[ui.toast]` delivery. `--position` only affects in-app Herdr toasts. `--sound` defaults to `none`; `done` and `request` play the existing finished and needs-attention sounds only when the notification is shown. + +## Sessions + +```bash +herdr session list [--json] +herdr session attach <name> +herdr session stop <name> [--json] +herdr session delete <name> [--json] +``` + +Use `default` as the session name when you need to stop the default session explicitly. + +## Workspaces + +```bash +herdr workspace list +herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus] +herdr workspace get <workspace_id> +herdr workspace focus <workspace_id> +herdr workspace rename <workspace_id> <label> +herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N] +herdr workspace close <workspace_id> [--group] +``` + +Create a workspace without stealing focus: + +```bash +herdr workspace create --cwd ~/project --label api --no-focus +``` + +A workspace is a top-level project or work context. Creating one also creates its first tab and root pane. The JSON response exposes their IDs as `.result.workspace.workspace_id`, `.result.tab.tab_id`, and `.result.root_pane.pane_id`. + +## Worktrees + +```bash +herdr worktree list [--workspace ID | --cwd PATH] [--trust-repository] +herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--trust-repository] +herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository] +herdr worktree remove --workspace ID [--force] [--trust-repository] +``` + +Worktrees are normal Herdr workspaces with Git checkout provenance. `worktree create` creates a Git worktree checkout, opens it as a workspace, and groups it with the parent repo workspace. If `--branch` names an existing local branch, Herdr checks it out; otherwise it creates the branch from `--base` or `HEAD`. Without `--path`, Herdr creates the checkout under `<worktrees.directory>/<repo>/<branch-slug>`. + +`workspace close` closes only Herdr state. Closing a primary workspace while linked-worktree workspaces are open requires `--group`; without it, the command leaves the group open and returns `workspace_group_close_required`. To delete the checkout, run `worktree remove`. It runs `git worktree remove`, never deletes the branch, and requires `--force` when Git refuses a dirty checkout. + +Git rejects repositories owned by another user by default. If you have independently verified the repository, pass `--trust-repository` to trust its resolved path for that command only. Herdr does not change your Git configuration. + +## Tabs + +```bash +herdr tab list [--workspace <workspace_id>] +herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus] +herdr tab get <tab_id> +herdr tab focus <tab_id> +herdr tab rename <tab_id> <label> +herdr tab close <tab_id> +``` + +A tab is another terminal layout inside a workspace. Without `--workspace`, `tab create` uses the active workspace and fails if none exists. Its JSON response exposes `.result.tab.tab_id` and `.result.root_pane.pane_id`. Closing a workspace's last tab also closes the workspace, matching the TUI close-tab action. If `confirm_close` is enabled and closing the tab would also close a whole worktree group, `tab close` returns a `confirmation_required` error instead. + +Workspace and tab creation, and pane splitting, leave focus unchanged by default. `--focus` selects the new layout; `--no-focus` states the default explicitly. Without `--cwd`, new terminals follow the configured `terminal.new_cwd` policy, which follows the source pane or workspace by default. Each `--env KEY=VALUE` adds or replaces that variable in the new root shell. + +## Panes + +```bash +herdr pane list [--workspace <workspace_id>] +herdr pane current [--pane ID|--current] +herdr pane get <pane_id> +herdr pane layout [--pane ID|--current] +herdr pane process-info [--pane ID|--current] +herdr pane neighbor --direction left|right|up|down [--pane ID|--current] +herdr pane edges [--pane ID|--current] +herdr pane focus --direction left|right|up|down [--pane ID|--current] +herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current] +herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off] +herdr pane rename <pane_id> <label>|--clear +herdr pane input [<pane_id>|--pane ID|--current] --right-click herdr|pane +herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--right-click herdr|pane] [--focus] [--no-focus] +herdr pane swap --direction left|right|up|down [--pane ID|--current] +herdr pane swap --source-pane ID --target-pane ID +herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus] +herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus] +herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus] +herdr pane close <pane_id> +``` + +For pane commands that accept `--current`, Herdr uses the calling pane's +`HERDR_PANE_ID` when the command runs inside a Herdr pane. For `pane split`, +an explicit pane id or `--pane ID` splits that pane. An omitted target splits +the calling pane when `HERDR_PANE_ID` is available, otherwise the focused pane. +`--current` requires a calling pane and errors when `HERDR_PANE_ID` is unavailable. +The split response exposes the new pane ID as `.result.pane.pane_id`. + +`pane input --right-click pane` forwards unmodified right-click gestures to a +mouse-reporting pane application. `herdr` restores the default pane menu. +Right-clicking the pane frame still opens Herdr's menu. `pane split +--right-click pane` applies the same policy to the new pane at creation. + +After `pane move`, use `.result.move_result.pane.pane_id` for later commands. A cross-workspace move changes the workspace-qualified pane ID; the prior value remains at `.result.move_result.previous_pane_id`. The running process keeps its launch-time `HERDR_PANE_ID`, `HERDR_TAB_ID`, and `HERDR_WORKSPACE_ID`; Herdr retains the old pane ID as an alias for that terminal, so pane commands using `--current` still resolve it. A live agent name follows the terminal and continues to resolve after the move. + +Read output: + +```bash +herdr pane read <pane_id> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] [--raw] +herdr pane read <pane_id> --source visible --ansi +herdr pane read <pane_id> --source recent-unwrapped --lines 120 +``` + +`pane read` prints UTF-8 terminal text directly. ANSI escapes are stripped by default; use `--format ansi` or `--ansi` to preserve them where the source exposes styling. The `detection` source is always plain text. For recent sources, `--lines N` selects the last N rendered terminal rows before optional unwrapping; without it, reads default to 80 rows. For `visible` and `detection`, omitting `--lines` returns the full snapshot, while specifying it keeps the last N newline-delimited lines. `agent read` uses the same output and line behavior. + +Send input: + +```bash +herdr pane send-text <pane_id> <text> +herdr pane send-keys <pane_id> <key> [key ...] +herdr pane run <pane_id> <command> +``` + +`<key>` uses Herdr key-combo syntax: plain printable keys such as `a`, +special keys such as `enter`, `tab`, `esc`, `backspace`, `left`, `right`, +`up`, and `down`, modifier chords such as `ctrl+h`, `control+j`, `alt+x`, +and `shift+tab`, function keys such as `f1`, and named punctuation such as +`minus`, `plus`, and `backtick`. Legacy `C-c` and `c-c` are accepted as +aliases for `ctrl+c`. +`esc` is the canonical spelling; `escape` is also accepted. + +`pane run` honors live bracketed-paste mode and submits text plus Enter atomically. Prefer it over `send-text` plus `send-keys Enter` for commands; the separate send operations remain low-level and non-submitting. + +Report agent state from custom hooks: + +```bash +herdr pane report-agent <pane_id> \ + --source ID \ + --agent LABEL \ + --state idle|working|blocked|unknown \ + [--message TEXT] \ + [--seq N] \ + [--agent-session-id ID] \ + [--agent-session-path PATH] + +herdr pane report-agent-session <pane_id> \ + --source ID \ + --agent LABEL \ + [--seq N] \ + [--agent-session-id ID] \ + [--agent-session-path PATH] \ + [--session-start-source SOURCE] + +herdr pane release-agent <pane_id> \ + --source ID \ + --agent LABEL \ + [--seq N] +``` + +`report-agent-session` updates native session identity without reporting lifecycle state. `release-agent` ends that source's lifecycle authority when its agent process exits. + +`pane get`, `pane list`, `agent get`, and `agent list` include a read-only `agent_session` object when an official integration has reported a native session reference. If no native session reference is stored, the field is omitted. + +Those commands include `foreground_cwd` when Herdr can resolve the cwd of the foreground process controlling the pane. The `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior. + +`pane get` and `pane list` include `scroll` when terminal scroll metrics are available. `scroll.offset_from_bottom == 0` means the pane is at the bottom of its scrollback. + +Report display-only pane metadata without taking over semantic state: + +```bash +herdr pane report-metadata <pane_id> \ + --source ID \ + [--agent LABEL] \ + [--applies-to-source ID] \ + [--title TEXT|--clear-title] \ + [--display-agent TEXT|--clear-display-agent] \ + [--state-label STATUS=TEXT] \ + [--clear-state-labels] \ + [--token NAME=VALUE] \ + [--clear-token NAME] \ + [--seq N] \ + [--ttl-ms N] +``` + +`STATUS` is one of `idle`, `working`, `blocked`, `done`, or `unknown`. `--agent` and `--applies-to-source` guard only `--title`, `--display-agent`, and `--state-label`. They do not guard token patches; token reporters own clearing or TTL refresh. Use `--display-agent` to change the visible name. + +Metadata text is normalized before storage. Herdr trims surrounding whitespace, removes control characters, and caps `--title`, `--display-agent`, each `--state-label`, and token values at 80 characters. Empty normalized token values clear that key. + +`--token` patches one named display value; `--clear-token` removes one. Unmentioned tokens remain unchanged. Pane tokens are available to Agent sidebar rows as `$name`; workspace tokens are available to Space rows. TTL applies independently to the token keys updated by that call. + +`--source` and `--applies-to-source` must be 80 characters or fewer and may contain only ASCII letters, digits, colon, dot, underscore, and hyphen. `--ttl-ms` makes metadata expire automatically and must be between `1` and `86400000` milliseconds. Omit it for metadata that should stay until replaced, cleared, or the pane closes. `--seq` lets Herdr ignore stale reports from the same `--source`; stale reports are accepted by the API but ignored by pane state. A pane or workspace accepts sequenced token reports from at most 32 distinct sources during its lifetime; clearing or expiry does not release those source slots. + +## Agents + +For the pane-versus-agent model and complete orchestration examples, see [Agent automation](/docs/agent-automation/). + +```bash +herdr agent list +herdr agent get <target> +herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] +herdr agent send-keys <target> <key> [key ...] +herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS] +herdr agent rename <target> <name>|--clear +herdr agent focus <target> +herdr agent wait <target> [--until STATUS]... [--timeout MS] +herdr agent attach <target> [--takeover] +herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>] +herdr agent explain <target> [--json|--verbose] +herdr agent explain --file PATH --agent LABEL [--json|--verbose] +``` + +Agent targets are either a unique live agent name or the pane ID that currently hosts the agent. Terminal IDs and bare agent-kind labels are not agent targets. Agents started through `agent start` require a name; manually launched agents remain unnamed and use their pane ID. + +`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `letta`, `maki`, and `muse`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. + +A successful start returns only after the expected agent owns the same terminal and is ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. + +`agent prompt` honors live bracketed-paste mode and writes text followed by delayed Enter as one ordered submission, including while the agent is working. Success without `--wait` acknowledges the writes, not the start of a turn. On Windows, Codex receives a paste boundary before Enter so submission does not depend on prompt size; the caller timeout includes submission time. If the agent is already `blocked`, it returns `agent_blocked` without sending input. With `--wait`, a prompt sent from another non-working state has up to five seconds after submission to produce an observed `working` or `blocked` state or Herdr returns `agent_prompt_stalled`; if the caller timeout expires first, Herdr returns the normal `timeout` error. This prevents unrelated `idle`, `done`, or session changes from completing the wait. After activity is observed, it waits for the first requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. `--until` narrows the matching states and is rejected unless `--wait` is also present. Standalone `agent wait` returns immediately when the current status matches. Both default to `idle`, `done`, or `blocked`; use `--until unknown` explicitly when needed. + +`idle` and `done` both mean ready for input. The CLI/API uses the server's seen state: `done` is idle but not yet marked seen, explicit `pane focus` / `agent focus` commands mark the target seen, and reads do not. Each TUI client tracks viewed completions independently, so its Done badge can differ from the CLI or another client. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify it confidently, not that its work succeeded. + +`agent send-keys` sends logical terminal keys such as `enter`, `up`, `esc`, or `ctrl+c`. Herdr validates every key before writing any bytes. `agent read` reads the resolved terminal stream, and `agent rename` names an already detected agent. + +`agent explain` asks the running server to classify the same bottom-buffer detection snapshot used by screen detection, so live output reflects the server's active manifest cache. Because this uses the `agent.explain` socket method, restart or hand off to an updated server after upgrading Herdr before using live explain. Use `--file PATH --agent LABEL` to explain a saved fixture locally instead. The default output shows the agent, final state, manifest source and version, matched rule with its region evidence, and any fallback, skip, or warning reasons. Add `--verbose` for visible evidence flags, cached remote version, local override shadowing, remote update status, and the full evaluated-rules list with matcher and region evidence. Add `--json` for issue reports or tests. + +Use `pane send-text`, `pane send-keys`, `pane run`, and `terminal attach` for ordinary terminals, servers, tests, shells, or low-level terminal control. Use `pane run` when you want to submit a command with Enter. + +## Direct terminal attach + +```bash +herdr terminal attach <terminal_id> [--takeover] +herdr terminal session control <target> [--takeover] [--cols N] [--rows N] +herdr terminal session observe <target> [--cols N] [--rows N] +herdr terminal title set <title> +herdr terminal title clear +``` + +Detach from direct attach with `ctrl+b q`. Send literal `ctrl+b` with `ctrl+b ctrl+b`. +`terminal session control` opens a writable live terminal stream for a pane, +terminal, or agent target. It prints the same newline-delimited +`terminal.frame` and `terminal.closed` records as observe mode. It reads +newline-delimited JSON commands on stdin: `terminal.input`, `terminal.resize`, +`terminal.scroll`, and `terminal.release`. One controller can own a terminal at +a time; use `--takeover` to replace it. +`terminal session observe` opens a read-only live terminal stream for a pane, +terminal, or agent target. It prints newline-delimited JSON `terminal.frame` +records with base64-encoded ANSI bytes, then a `terminal.closed` record when +the server closes the stream. Multiple observers can watch the same terminal +without taking input, resize, scroll, or takeover authority. +On Linux and macOS, an observer is disconnected if a socket write makes no progress +for 30 seconds. A stalled stream can end without a final `terminal.closed` record; +the pane and other clients keep running. Quiet panes do not trigger this timeout. +`terminal title clear` hands the outer terminal window title back to `ui.window_title`. + +## Output waits + +Wait for output in a pane: + +```bash +herdr pane wait-output <pane_id> (--match <text> | --regex <pattern>) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw] +``` + +Use `pane wait-output` for normal commands and servers. Use `agent wait` for coding agents. + +`pane wait-output` checks the selected snapshot immediately, including output that already exists, then polls until it matches. The default source name is `recent`; matching treats it as unwrapped recent output from the latest 80 rendered terminal rows. `--lines` changes that row limit. `--match` finds a literal substring on one line, and `--regex` uses Rust regular-expression syntax and also matches one line at a time. + +A timeout or `agent_prompt_stalled` does not prove the prompt was never delivered. Inspect the agent before retrying. + +`pane wait-output` and `agent wait` wait indefinitely when `--timeout` is omitted. For `agent prompt --wait`, the settled-state wait is indefinite after activity is observed or when the prompt starts in `working`; a non-working prompt still returns `agent_prompt_stalled` after five seconds without observed activity. A timeout or server error is emitted as JSON on stderr with exit status 1. CLI usage errors exit with status 2. + +## Integrations + +```bash +herdr integration install pi +herdr integration install omp +herdr integration install claude +herdr integration install codex +herdr integration install copilot +herdr integration install devin +herdr integration install droid +herdr integration install kimi +herdr integration install opencode +herdr integration install kilo +herdr integration install hermes +herdr integration install qodercli +herdr integration install qwen +herdr integration install letta +herdr integration install cursor +herdr integration install mastracode +herdr integration install grok +herdr integration uninstall pi +herdr integration uninstall omp +herdr integration uninstall claude +herdr integration uninstall codex +herdr integration uninstall copilot +herdr integration uninstall devin +herdr integration uninstall droid +herdr integration uninstall kimi +herdr integration uninstall opencode +herdr integration uninstall kilo +herdr integration uninstall hermes +herdr integration uninstall qodercli +herdr integration uninstall qwen +herdr integration uninstall letta +herdr integration uninstall cursor +herdr integration uninstall mastracode +herdr integration uninstall grok +herdr integration status [--outdated-only] +``` + +## Plugins + +Plugin commands install and run local executable workflow plugins. A plugin is a manifest plus out-of-process commands; Herdr owns the host surface and plugins own their implementation language. + +Install, list, and remove plugins: + +```bash +herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes] +herdr plugin list [--plugin ID] [--json] +herdr plugin uninstall <plugin_id|owner/repo[/subdir...]> +herdr plugin enable <plugin_id> +herdr plugin disable <plugin_id> +``` + +`plugin install` accepts GitHub shorthand only, such as `ogulcancelik/herdr-plugin-examples/worktree-bootstrap`. It uses `git`, shows a trust preview in interactive terminals, runs supported manifest build commands, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. Installing over a locally linked plugin is refused. Plugin manifests must declare `min_herdr_version`; install and link fail when the plugin requires a newer Herdr binary. `plugin list` is human-readable by default; pass `--json` for the raw API response. + +Plugin installation and enabled state are global to the current user. A plugin installed, linked, enabled, or disabled through one Herdr session is immediately available with the same state in every session. + +Local development: + +```bash +herdr plugin link <path> [--disabled] +herdr plugin unlink <plugin_id> +``` + +`plugin link` accepts a plugin directory containing `herdr-plugin.toml` or a direct manifest path. Use it while authoring or testing a plugin from a local checkout. `plugin unlink` unregisters the plugin and leaves files alone. `plugin uninstall` unregisters a plugin and also removes Herdr-managed GitHub checkout files. For GitHub installs, uninstall accepts either the plugin id or the same `owner/repo[/subdir...]` shorthand used by install. Actions, event hooks, panes, and link handlers are declared in the manifest; runtime action registration is not part of v1. + +Config directory: + +```bash +herdr plugin config-dir <plugin_id> +``` + +`plugin config-dir` prints the plugin's config directory. It creates the directory if needed and seeds it from legacy plugin config locations when present. Use it in setup docs and shell scripts to point users at a stable path for `.env` files and other user-editable config, separate from the managed plugin checkout. + +Actions: + +```bash +herdr plugin action list [--plugin ID] +herdr plugin action invoke <action_id> [--plugin ID] +``` + +`plugin action invoke` starts the manifest command for an installed, enabled, +platform-compatible plugin action and prints the started command log record in +the JSON response. Use the qualified action id (`plugin.id.action`) when more +than one plugin uses the same action id. Local action ids cannot contain dots, +so qualified ids remain unambiguous even when plugin ids contain dots. + +Logs: + +```bash +herdr plugin log list [--plugin ID] [--limit N] +``` + +Managed terminal panes: + +```bash +herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|popup|split|tab|zoomed] [--width SIZE] [--height SIZE] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus] +herdr plugin pane focus <pane_id> +herdr plugin pane close <pane_id> +``` + +`plugin pane open` requires the plugin to be linked, enabled, and compatible +with the current platform. It starts a manifest-declared `[[panes]]` command as +a Herdr-managed terminal pane. The manifest default is `overlay`, which opens a +temporary zoomed overlay over the active pane. It can also open as a split, a +new tab, a zoomed pane, or a session-modal `popup` that does not change the tab +layout. `--width` and `--height` set the outer popup dimensions in terminal +cells or percentages such as `80%`; omitted dimensions default to half the +terminal size, and values smaller than the popup minimum are clamped. A popup +is not a Herdr pane, does not export `HERDR_PANE_ID`, and does not participate +in pane or agent APIs. Native non-terminal plugin panes are outside plugin v1. + +`--env KEY=VALUE` can be repeated on process-launching commands. It applies to the newly launched process only. Herdr-managed variables such as `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ENV`, `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, `HERDR_PANE_ID`, `HERDR_PLUGIN_ID`, `HERDR_PLUGIN_ROOT`, `HERDR_PLUGIN_CONFIG_DIR`, `HERDR_PLUGIN_STATE_DIR`, `HERDR_PLUGIN_ENTRYPOINT_ID`, and `HERDR_PLUGIN_CONTEXT_JSON` stay authoritative when they conflict with caller-provided env. + +## Read sources + +| Source | Meaning | +| --- | --- | +| `visible` | Current rendered screen. Best for UI feedback loops. | +| `recent` | Recent scrollback with terminal wrapping. | +| `recent-unwrapped` | Recent scrollback without soft wrapping. Best for logs. | +| `detection` | Bottom-buffer snapshot used by agent screen detection. | + +These meanings apply to reads. For `pane wait-output` only, both `recent` and `recent-unwrapped` search the unwrapped recent snapshot; `recent` remains the default spelling. + +## Environment variables + +| Variable | Purpose | +| --- | --- | +| `HERDR_CONFIG_PATH` | Override the config file path. | +| `HERDR_SESSION` | Select a named session for CLI commands. | +| `HERDR_SOCKET_PATH` | Low-level socket path override. | +| `HERDR_PROCESS_DETECTION` | Linux process detection strategy: `native` (default) or opt-in `child-groups`. | +| `HERDR_ENV` | Set to `1` inside Herdr-managed pane processes. | +| `HERDR_PANE_ID` | Public pane id for the running pane process. | +| `HERDR_TAB_ID` | Public tab id for the running pane process. | +| `HERDR_WORKSPACE_ID` | Public workspace id for the running pane process. | +| `HERDR_LOG` | Set log filter, for example `HERDR_LOG=herdr=debug`. | +| `HERDR_DISABLE_SOUND` | Disable sound playback even when sound notifications are enabled. | diff --git a/docs/versions/0.9.1/website/src/content/docs/concepts.mdx b/docs/versions/0.9.1/website/src/content/docs/concepts.mdx new file mode 100644 index 00000000..c45ec545 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/concepts.mdx @@ -0,0 +1,89 @@ +--- +title: Concepts +description: Understand Herdr workspaces, tabs, panes, agents, sessions, and modes. +--- + +Herdr is a terminal workspace manager. It keeps real terminal processes running and adds structure around them. + +## Workspace + +A workspace is the top-level project container. Use one workspace per repo, task, or investigation. + +A workspace owns tabs and panes. Its sidebar state rolls up from the agents inside it, so you can see which project needs attention. + +## Tab + +A tab is a layout inside a workspace. Use tabs to separate views like `agents`, `logs`, `server`, or `review`. + +Tabs are addressable from the CLI and socket API. + +## Pane + +A pane is a real terminal. Herdr renders the terminal output, sends input back to the process, and preserves the pane across client detach. + +Panes can be split right or down. They can be renamed manually, read from the CLI, sent input, and closed. + +## Mouse UI + +Herdr is mouse-native. You can click panes, tabs, workspaces, and agents. You can drag split borders, select text, and use right-click menus. Everything below is also reachable by mouse; keyboard bindings are an optional layer. + +If you prefer keyboard-only control, or you want Herdr to stop capturing mouse input, disable mouse capture: + +```toml +[ui] +mouse_capture = false +``` + +## Agent + +An agent is a process Herdr recognizes inside a pane. Herdr detects agents from foreground processes, screen manifests, and optional integrations. + +Agent states are: + +| State | Meaning | +| --- | --- | +| `blocked` | The agent needs input, approval, or a decision. | +| `working` | The agent is actively running. | +| `done` | The agent finished and you have not looked at it yet. | +| `idle` | The agent is finished or waiting and has been seen. | +| `unknown` | Herdr cannot confidently classify the state. | + +Each client tracks which completions it has displayed. Viewing a completion in one client does not clear another client's Done badge. CLI/API statuses use the server's seen state, so they need not match a particular client's badge; both `idle` and `done` mean ready for input. + +## Session + +A session is a persistent Herdr server namespace. The default `herdr` command attaches to the default session. + +Named sessions are separate runtime namespaces: + +```bash +herdr session list +herdr session attach work +herdr session attach side-project +``` + +Use workspaces first. Use named sessions when you need completely separate panes, sockets, and persisted runtime state. + +## Client and server + +By default, Herdr runs as a background server plus one or more attached clients. + +The server owns panes and process state. The client is the terminal UI attached to that server. + +With one attached client, all tabs follow its size as before. With multiple clients, each can view its own workspace and tab. Different viewed tabs follow their respective clients; when clients view the same tab, the last one to focus, select, or interact with it controls that tab's pane sizes. + +Detach the client with `ctrl+b q`. The server and agents continue running. + +If you want to end the session and stop its panes, stop the server: + +```bash +herdr server stop +``` + +## Modes + +Herdr has terminal mode, prefix mode, and navigate mode. + +Terminal mode sends keys to the focused pane. Prefix mode waits for one Herdr action after the prefix key. Navigate mode is the persistent workspace navigation surface. + +Press the prefix key, default `ctrl+b`, then an action key such as `c` for a new tab or `w` for workspace navigation. See [Keyboard](/docs/keyboard/) if the prefix idea is new to you. diff --git a/docs/versions/0.9.1/website/src/content/docs/config-reference.mdx b/docs/versions/0.9.1/website/src/content/docs/config-reference.mdx new file mode 100644 index 00000000..8ea9ec79 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/config-reference.mdx @@ -0,0 +1,21 @@ +--- +title: Config reference +description: Every canonical config.toml key, with types, defaults, and allowed values. +--- + +import ConfigReference from '../../components/ConfigReference.astro'; + +Browse every canonical key Herdr reads from `config.toml` in a flat, filterable list. For setup guidance +and the reasoning behind these options, see [Configuration](/docs/configuration/). + +Print the full commented default config at any time: + +```bash +herdr --default-config +``` + +Custom command bindings (`[[keys.command]]`) are user-defined tables and are not +listed per key here; see +[Custom command keybindings](/docs/configuration/#custom-command-keybindings). + +<ConfigReference /> diff --git a/docs/versions/0.9.1/website/src/content/docs/configuration.mdx b/docs/versions/0.9.1/website/src/content/docs/configuration.mdx new file mode 100644 index 00000000..1975a43a --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/configuration.mdx @@ -0,0 +1,589 @@ +--- +title: Configuration +description: Configure Herdr keybindings, themes, sidebar behavior, notifications, and advanced options. +--- + +Herdr works without a config file. Add one when you want custom keys, themes, sidebar layouts, notifications, or advanced behavior. + +The [Config reference](/docs/config-reference/) lists every setting and keybinding, with types, defaults, and allowed values. This page covers setup, common recipes, and configuration structures that need more explanation than a reference row. + +## Config file + +Herdr reads config from: + +```text +Linux and macOS: ~/.config/herdr/config.toml +Windows: %APPDATA%\herdr\config.toml +``` + +Run `herdr --help` to see the resolved config path for your system. + +Print the full default config: + +```bash +herdr --default-config +``` + +Save it as your config if you want a complete starting point: + +```bash +herdr --default-config > ~/.config/herdr/config.toml +``` + +If a config value is invalid, Herdr falls back to a safe default and shows a startup warning. + +Herdr shows first-run setup when `onboarding` is missing or true. Continuing from onboarding writes `onboarding = false` and opens settings on the integrations tab. Set `onboarding = false` to skip that flow after setup. + +```toml +onboarding = false +``` + +## Reload config + +Reload a running server after editing `config.toml`: + +```bash +herdr server reload-config +``` + +You can also open the global menu in Herdr and choose `reload config`. + +Reload applies most UI settings without restarting panes. Startup-only settings still need a restart. + +Themes, sidebar layouts, copy behavior, and other presentation settings come from the client's local config, including when viewing an SSH machine. Pane defaults, worktrees, integrations, and custom commands belong to the server where the panes run. The UI's `reload config` action reloads both the client's local settings and the selected server's config. Local keybindings reload too; `--remote-keybindings server` instead uses the selected server's keybindings. + +## Headless terminal size + +When no client is attached, the server uses a 120×40 virtual terminal for layout and newly created panes. Change that fallback for headless orchestration with: + +```toml +[server] +headless_cols = 160 +headless_rows = 50 +``` + +With one attached client, all tabs follow its size. With multiple clients, each viewed tab follows the client that most recently focused, selected, or interacted with it. When all but one client detach, all tabs immediately return to the remaining client's size. When no clients remain, existing pane PTYs retain their last size and new headless layout uses the configured fallback. + +## Terminal defaults + +Set the executable Herdr uses for newly created interactive panes: + +```toml +[terminal] +default_shell = "nu" +``` + +When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix and PowerShell on Windows. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`. + +Set how Herdr starts newly created interactive pane shells: + +```toml +[terminal] +shell_mode = "auto" +``` + +`shell_mode = "auto"` starts login shells on macOS so login-only PATH setup such as `/usr/libexec/path_helper` and Homebrew shell initialization runs in new panes. On other platforms, it keeps the existing non-login shell behavior. Use `"login"` to force login-shell startup or `"non_login"` to force non-login startup. Command panes, detached custom command keybindings, and explicit argv launches keep their existing command execution paths. + +Set the working directory policy for new panes, tabs, and workspaces: + +```toml +[terminal] +new_cwd = "follow" +``` + +`new_cwd = "follow"` keeps the default behavior and inherits the source pane or workspace. When there is no source workspace, Herdr starts in `$HOME`. Use `"home"` to always start in `$HOME`, `"current"` to use Herdr's process directory, or a fixed path such as `"~/Projects"`. Explicit `--cwd` values from the CLI or socket API still take precedence. + +## Worktrees + +Set the root directory Herdr uses for Git worktree checkouts created from the sidebar: + +```toml +[worktrees] +directory = "~/.herdr/worktrees" +``` + +Herdr creates checkouts under `<directory>/<repo>/<branch-slug>`. For sibling-style checkouts, set this to a directory such as `~/Projects/herdr-worktrees`. Relative values are resolved to an absolute path when the app applies the config. + +Worktree actions are available from Git workspace rows. `New worktree` creates a checkout. It checks out an existing local branch when the entered branch exists; otherwise, it creates the branch. It then opens the checkout as a new Herdr workspace and groups it under the source workspace. `Open worktree...` lists existing Git worktree checkouts for that repo. Choosing an already-open checkout focuses it, while choosing a closed checkout opens it in the same group. + +Grouped worktrees still behave like normal Herdr workspaces: they can be focused, renamed, closed, and contain their own tabs and panes. The parent row is the original workspace. Closing the parent row closes the whole Herdr group, but it does not delete checkout folders or branches. + +To delete a worktree checkout, use `Delete worktree checkout...` on a grouped child workspace. Herdr runs `git worktree remove`, first asking Git to remove safely. If Git refuses because the checkout has modified or untracked files, Herdr asks again before running the forced remove. Branches are not deleted. + +## Remote attach + +Remote attach manages its SSH connection with temporary keepalives and, where supported, connection reuse by default. + +```toml +[remote] +manage_ssh_config = true +``` + +When enabled, `herdr --remote` writes a private temporary SSH config that includes your user and system SSH configs first, then adds fallback `ServerAliveInterval` and `ServerAliveCountMax` values. Your own keepalive settings win. Linux and macOS clients also use a private per-attach OpenSSH control socket to reuse the first authenticated connection; Windows OpenSSH does not. Set `manage_ssh_config = false` to run remote attach through plain `ssh` without Herdr's generated config or control socket. + +## Keybindings + +For a guided introduction to the prefix and a vetted prefix-free setup, see [Keyboard](/docs/keyboard/). + +Herdr has a prefix mode similar to tmux. The default prefix is `ctrl+b`. Keybinding strings are explicit: `prefix+n` means press the configured prefix and then `n`; `ctrl+alt+n` is a direct terminal-mode shortcut. + +A small keybinding override looks like this: + +```toml +[keys] +prefix = "ctrl+b" +goto = "prefix+g" +new_tab = "prefix+c" +next_tab = "prefix+n" +previous_tab = "prefix+p" +focus_pane_left = "prefix+h" +navigate_workspace_down = "j" +navigate_pane_down = "ctrl+j" +split_horizontal = "prefix+minus" +``` + +The default keymap is prefix-first so Herdr does not steal input from shells, editors, tmux, or terminal apps. Search `keys.` in the [Config reference](/docs/config-reference/) to see every action and default binding. The in-app help panel at `prefix+?` shows the active bindings. + +A binding may also be an array when one action needs multiple shortcuts: + +```toml +[keys] +next_tab = ["prefix+n", "ctrl+alt+]"] +``` + +Optional actions are unset by default. Bind them with `prefix+` for prefix-mode behavior, or use an explicit modified chord when you intentionally want a direct shortcut. For example, tmux-style one-keystroke pane resizing without entering resize mode: + +```toml +[keys] +resize_pane_left = "ctrl+shift+alt+left" +resize_pane_down = "ctrl+shift+alt+down" +resize_pane_up = "ctrl+shift+alt+up" +resize_pane_right = "ctrl+shift+alt+right" +``` + +Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. The `navigate_workspace_*` and `navigate_pane_*` fields are navigate-mode-only and may use plain keys such as `j` or `k`; they must not use `prefix+`, `esc`, `enter`, `tab`, `shift+tab`, `left`, `right`, or unmodified `1` through `9`. Left and right arrows are permanent aliases for pane-left and pane-right navigation. These navigate-mode shortcuts are independent from general action bindings such as `focus_pane_down = "prefix+j"`; when both use the same key, the navigate-mode shortcut wins while navigate mode is open. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings. + +If you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. Herdr backs up `config.toml`, removes `[keys]` and `[[keys.command]]`, and uses built-in v2 defaults after restart or `herdr server reload-config`. + +## Indexed jumps + +Indexed keybindings use `1..9` in normal keybinding fields: + +```toml +[keys] +switch_tab = "prefix+1..9" +switch_workspace = "prefix+shift+1..9" +focus_agent = "prefix+alt+1..9" +``` + +The legacy `[keys.indexed]` table is still parsed for compatibility, but new configs should prefer the explicit action fields. + +## Custom command keybindings + +Custom commands use the same keybinding syntax. + +```toml +[[keys.command]] +key = "prefix+alt+g" +type = "popup" +command = "lazygit" +description = "run lazygit" +width = "80%" +height = "80%" +``` + +`type = "popup"` opens a session-modal popup without changing the tab layout. +The popup receives all terminal input, including Escape, until its command +exits. `width` and `height` are optional; omit them for the default half-size +popup, use numbers for terminal cells, or use strings like `"80%"` for a +percentage of the terminal area. Dimensions include the popup border, and +values smaller than the popup minimum are clamped. Popup commands do not receive +`HERDR_PANE_ID`; use `HERDR_ACTIVE_PANE_ID` for the underlying tiled pane. + +On Unix and macOS, a popup command can also provide an ad-hoc terminal without +adding a split or tab: + +```toml +[[keys.command]] +key = "prefix+t" +type = "popup" +command = "exec \"${SHELL:-sh}\"" +description = "open scratch terminal" +width = "80%" +height = "80%" +``` + +On Windows, use a shell command such as `command = "powershell.exe -NoLogo"` +instead. Exit the shell to close the popup and restore the tiled terminal view. + +`type = "pane"` opens a temporary zoomed pane and closes it when the command exits. + +`type = "shell"` runs detached in the background. + +`type = "plugin_action"` invokes an installed plugin action id. Use the +qualified id when action ids are not globally unique: + +```toml +[[keys.command]] +key = "prefix+l" +type = "plugin_action" +command = "example.layout.apply" +description = "apply layout" +``` + +`description` is optional. When set, it appears in the keybind help panel (opened with `prefix+?`) instead of the default `'custom command'` label. + +Custom commands receive `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ACTIVE_WORKSPACE_ID`, `HERDR_ACTIVE_TAB_ID`, `HERDR_ACTIVE_PANE_ID`, and `HERDR_ACTIVE_PANE_CWD` when those values are available. Shell commands run from the focused pane's working directory when Herdr can detect it. + +On Windows, custom command strings use `cmd.exe /d /c`, so environment variables use `%HERDR_BIN_PATH%` syntax. To run PowerShell syntax, invoke it explicitly, for example `powershell.exe -NoProfile -Command "..."`. + +## Theme + +Choose a built-in theme: + +```toml +[theme] +name = "catppuccin" +``` + +Search `theme.name` in the [Config reference](/docs/config-reference/) for every built-in theme. Use `terminal` when you want Herdr UI colors to follow your host terminal's ANSI palette. + +To let Herdr switch its own UI theme when the host terminal reports a light/dark appearance change, enable theme auto-switching: + +```toml +[theme] +name = "catppuccin" +auto_switch = true +light_name = "catppuccin-latte" +dark_name = "catppuccin" +``` + +`auto_switch` defaults to `false`, so existing theme configs keep manual behavior. If `light_name` or `dark_name` is omitted, Herdr uses the matching built-in sibling for the configured `name` when one exists, such as `tokyo-night`/`tokyo-night-day` or `gruvbox`/`gruvbox-light`. Manual theme selection in Settings disables `auto_switch`. + +You can override individual colors: + +```toml +[theme.custom] +sidebar_bg = "#181825" +active_row_bg = "#1e1e2e" +selection_bg = "#313244" +panel_bg = "reset" +accent = "#a6e3a1" +green = "#a6e3a1" +blue = "#89b4fa" +red = "#f38ba8" +yellow = "#f9e2af" +``` + +`sidebar_bg` optionally gives the desktop sidebar its own background. When omitted, the sidebar keeps the host terminal background. `active_row_bg` changes the active Space and focused Agent row background without affecting separators or scrollbar tracks. `selection_bg` changes the Navigate-mode cursor row background in the sidebar. + +Color values accept hex, named colors, `rgb(r,g,b)`, or reset aliases like `reset`, `default`, `none`, and `transparent`. + +When `auto_switch` is enabled, optional light and dark subtables layer on top of the shared custom colors: + +```toml +[theme.custom] +accent = "#89b4fa" + +[theme.custom.light] +panel_bg = "#eff1f5" +text = "#4c4f69" + +[theme.custom.dark] +panel_bg = "#1e1e2e" +text = "#cdd6f4" +``` + +The active palette is applied in this order: built-in theme, `[theme.custom]`, then `[theme.custom.light]` or `[theme.custom.dark]`. Omitting the mode subtables preserves the existing shared override behavior. + +## UI and sidebar + +The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference](/docs/config-reference/) for sizing, collapsed mode, Agent panel ordering, mouse behavior, pane borders, and other presentation settings. + +`ui.pane_borders` accepts `"auto"` (the default, borders only for split panes), `"always"` (also frame a single pane), or `"off"`. A single-pane frame requires `ui.pane_outer_borders = true`, because all its edges are outer edges. Existing boolean values remain valid: `true` means `"auto"` and `false` means `"off"`. + +Set `tab_bar_position = "bottom"` under `[ui]` to place the desktop tab row below the terminal panes. Prefix, Navigate, Copy, and Resize mode bars temporarily replace the bottom tab row while active. The default is `"top"`. + +Configure an ordered tmux-style status area at the right edge of the tab row: + +```toml +[ui] +tab_bar_right = [ + { type = "zoom" }, + { type = "hostname" }, + { type = "datetime", format = "%H:%M" }, + { type = "text", text = "prod" }, + { type = "command", command = "~/.config/herdr/status.sh", interval_seconds = 5, timeout_seconds = 2 }, +] +tab_bar_right_separator = " · " +``` + +The status area is empty by default. Add `zoom` to show a fixed `ZOOM` pill while the active tab is zoomed; the existing per-tab `Z` markers remain independent. `hostname`, `datetime`, and `command` resolve on the Herdr server, so `herdr --remote` shows the remote machine's values. Datetime entries use `strftime` formatting; directives that require a UTC offset or Unix timestamp, such as `%z` and `%s`, are rejected because the value is server-local wall-clock time. + +Command entries run immediately and then at `interval_seconds` without blocking rendering or overlapping a previous run. The interval can be 1–31,536,000 seconds and the timeout can be 1–3,600 seconds. Herdr uses the last line of successful output, removes ESC-prefixed terminal control sequences instead of interpreting styles, clears the entry after failure, empty output, or `timeout_seconds`, and provides the same active workspace, tab, pane, socket, binary, and working-directory context as custom command keybindings. Commands are supported on Linux, macOS, and Windows, using `/bin/sh -lc` on Linux and macOS and `cmd.exe /d /c` on Windows. + +Separators appear only between visible entries. Set `tab_bar_right_separator = ""` for direct concatenation. On a narrow tab row, the complete status area yields to the tabs and their controls. + +### Outer terminal window title + +Herdr emulates the terminals in its panes, so an `OSC 0`/`OSC 2` title written inside a pane stops at Herdr. Herdr writes its own title to the terminal it runs in, which is what window managers and terminal tab bars read: + +```toml +[ui] +window_title = "{hostname}: {workspace}" +``` + +Tokens are `{hostname}`, `{workspace}`, `{tab}`, `{pane}` (the focused pane's manual name), and `{terminal_title}` (the focused pane's own terminal title with spinner frames stripped). Write `{{` and `}}` for literal braces. A token with no value renders empty. + +The title renders on the Herdr server, so `{hostname}` names the machine the panes run on, including when you attach with `herdr --remote` or run `herdr` over SSH. Set `window_title = ""` to leave the outer terminal title alone. + +`client.window_title.set` overrides the configured title until `client.window_title.clear` hands it back. + +Agent status uses compact colored dots by default. To distinguish blocked, working, done, idle, and unknown states by shape as well as color, choose **distinct symbols** in Settings or configure: + +```toml +[ui] +status_indicators = "symbols" +``` + +The symbols are static, so this option does not enable spinner animation. + +### Sidebar row layouts + +The expanded desktop sidebar renders each inner array in `rows` as one line. These are the complete default layouts: + +```toml +[ui.sidebar.agents] +row_gap = 0 +rows = [ + ["state_icon", "machine", "workspace", "tab"], + ["agent"], +] + +[ui.sidebar.spaces] +row_gap = 0 +rows = [ + ["state_icon", "workspace"], + ["branch", "git_status"], +] +``` + +Agent rows accept these built-in tokens: + +- `state_icon` — colored icon for the agent's semantic state. +- `state_text` — `idle`, `working`, `blocked`, `done`, or `unknown`, including a reported display label when present. +- `machine` — machine label when the client has multiple machines; omitted for a single local machine. +- `workspace` — workspace name. +- `tab` — tab name when available. +- `pane` — pane name when available. +- `agent` — detected or reported agent display name. +- `terminal_title` — latest OSC 0/2 terminal title after safety normalization. +- `terminal_title_stripped` — the terminal title with one recognized leading activity or spinner glyph and its following whitespace removed. +- `$name` — custom pane metadata named `name`. + +Space rows accept these built-in tokens: + +- `state_icon` — colored icon for the space's rolled-up agent state. +- `state_text` — text for the rolled-up agent state. +- `workspace` — workspace name. +- `branch` — Git branch when available. +- `git_status` — Git ahead and behind counts when nonzero. +- `$name` — custom workspace metadata named `name`. + +Tokens render in their configured order. Herdr normally separates adjacent values with ` · ` and uses a single space after `state_icon`. Missing values and their separators disappear; a row disappears when none of its tokens have a value. Existing custom rows remain unchanged, so add `machine` explicitly if you want machine identity in a custom multi-machine layout. Each layout may contain at most 16 rows, with at most 16 tokens in each row. + +A token entry can also be an inline style table: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", { token = "workspace", bold = false }, "tab"], + [{ token = "$summary", fg = "#89b4fa", bold = true, dim = false }], +] +``` + +`fg` accepts strict `#RGB` or `#RRGGBB`. `bold` and `dim` accept booleans. Omitted fields preserve the token's contextual style; explicit `false` removes that modifier. Styling applies to one occurrence, so the same token may look different in another row or agent override. A foreground override replaces all semantic foregrounds inside that occurrence: for example, styled `git_status` ahead and behind counts use one color instead of their default green and red. Token styles never change separators or row backgrounds. + +Text-valued tokens also accept up to 16 ordered `rules`. Each rule contains exactly one condition—`equals`, `contains`, `starts_with`, `gt`, or `lt`—and optional `fg`, `bold`, and `dim` overrides: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "workspace", "tab"], + [{ token = "machine", fg = "#fff", rules = [{ equals = "Local", fg = "#f55" }, { equals = "Fedora", ignore_case = true, fg = "#51a2da" }] }, "agent"], + [{ token = "$load", fg = "#fff", rules = [{ gt = 80, fg = "#f55", bold = true }, { gt = 50, fg = "#fc0" }] }], +] +``` + +The first matching rule wins. Its specified style fields override the occurrence's defaults; unspecified fields inherit them. If no rule matches, the defaults remain. Matching uses the full token value before display truncation. Set `hide = true` on a rule to remove the matching token and its separator; rows with no remaining tokens also disappear. For example, `{ token = "machine", fg = "#61afef", rules = [{ equals = "Local", hide = true }] }` hides the machine token when its label is exactly `Local`. This compares the label, not the connection type. With `hide = false` or omitted, the token stays visible. A rule with no overrides still stops matching and keeps the defaults. + +`equals`, `contains`, and `starts_with` take strings and are case-sensitive. Add `ignore_case = true` for ASCII case-insensitive matching; non-ASCII characters remain case-sensitive. Empty strings follow ordinary string matching: empty `equals` matches only an empty value, while empty `contains` or `starts_with` matches any present value. + +`gt` and `lt` take finite numeric thresholds and compare strictly greater or less, not equal. Values must parse completely as finite numbers; decimal and exponent forms work, but whitespace, units, `NaN`, and infinity do not match. `ignore_case` is not accepted on numeric rules. Comparisons use floating-point numbers, not exact large-integer arithmetic. + +Rules work with text-valued built-ins and custom `$name` tokens in Agent rows, `rows_by_agent` overrides, and Space rows. Custom tokens match their reported value: `$load` reporting `"90"` selects red and bold above, `"60"` selects yellow, and `"90%"` keeps white. Unreported tokens still disappear. `state_icon` and composite `git_status` accept fixed styles only, not rules. Unknown conditions and malformed rules are rejected when loading config; regex, fuzzy matching, and scripts are not supported. + +`row_gap` controls the blank terminal rows between entries, independently for the Agent and Space panels. It defaults to `0`, which packs entries together; set it to `1` to restore the previous spacing. It does not add spacing between the content lines declared in `rows`. Consecutive indented worktree children remain packed as one Space group. + +Override the complete Agent layout for a known agent under `rows_by_agent`: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "agent", "state_text"], + ["workspace", "tab"], +] + +[ui.sidebar.agents.rows_by_agent] +claude = [ + ["state_icon", "agent", "state_text"], + ["terminal_title_stripped"], + ["workspace", "tab"], +] +``` + +An override replaces `rows`; it does not extend it. Override keys are case-sensitive canonical agent IDs such as `claude`, `codex`, and `pi`. Detection aliases such as `claude-code` are not accepted. Agents without an override, including custom reported agents, use `rows`. + +Custom `$name` tokens are dynamic values, not literal text. Add the token to a layout, then report its value from a script or plugin: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "agent", "$model"], + ["$summary"], + ["workspace", "tab"], +] +``` + +```bash +herdr pane report-metadata <pane_id> \ + --source my-agent-hook \ + --token model=opus \ + --token summary="reviewing authentication" +``` + +Use `herdr workspace report-metadata` in the same way for custom Space tokens. Unreported custom tokens disappear. + +Metadata reporters provide values only; styling stays in the local sidebar configuration. See [CLI reference: report metadata](/docs/cli-reference/#panes) for limits, clearing, sequencing, and expiry. + +Sidebar row settings affect only the expanded desktop sidebar. Collapsed and mobile views keep their compact layouts. + +## Notifications + +Herdr can notify you when a background agent finishes or needs input: + +```toml +[ui.toast] +delivery = "herdr" +delay_seconds = 1 + +[ui.toast.herdr] +position = "bottom-right" +``` + +Choose `herdr` for an in-app toast, `terminal` for an outer-terminal notification that also works over SSH, `system` for the local OS notification service, or `off` to disable popups. Herdr suppresses popups for the active tab. Search `ui.toast` in the [Config reference](/docs/config-reference/) for positions, delay behavior, and clipboard feedback settings. + +On macOS, `system` tries `terminal-notifier` first and falls back to `/usr/bin/osascript` when it is unavailable or fails. The fallback appears as Script Editor in Notification Center and cannot activate the hosting terminal. Install `terminal-notifier` with `brew install terminal-notifier`. For a supported, detected terminal, it can activate the terminal app when you click the notification. Alternatively, choose `terminal` to let a supported outer terminal own the notification. + +## Sound + +Sound notifications play through the local Herdr client. Custom sounds must be mp3 files; Herdr resolves relative paths from the config file's directory. + +```toml +[ui.sound] +path = "sounds/notification.mp3" +done_path = "sounds/done.mp3" +request_path = "sounds/request.mp3" +``` + +`path` sets one sound for all sound notifications. `done_path` and `request_path` override only the finished and needs-input sounds. + +Per-agent sound overrides accept `default`, `on`, or `off`. Use detected agent labels such as `claude`, `codex`, `devin`, or `droid` as keys. Droid is muted by default. + +```toml +[ui.sound.agents] +droid = "off" +claude = "on" +``` + +## Advanced configuration + +Search the [Config reference](/docs/config-reference/) for scrollback limits, nested launches, and other advanced or experimental settings. See [Session state and restore](/docs/session-state/) before enabling pane screen history; that guide explains the security trade-off of saving pane contents. + +## Kitty graphics + +Herdr renders pane images by default in compatible outer terminals. Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. + +To disable graphics rendering and the pane graphics API: + +```toml +[terminal] +kitty_graphics = false +``` + +The legacy `experimental.kitty_graphics` setting remains accepted for existing configurations. `terminal.kitty_graphics` takes precedence when both are set. + +Changing this setting requires restarting the affected Herdr server or reattaching the client. In remote sessions, the server's setting controls pane graphics parsing and API availability, while the local client's setting controls output to the outer terminal. + +## Agent session restore + +Herdr resumes supported Agent conversations after a server restart by default: + +```toml +[session] +resume_agents_on_restore = true +``` + +Only panes with a valid native session reference from an official integration can resume; other panes restore as normal shells. See [Session state and restore](/docs/session-state/) for supported Agents and persistence behavior. + +## IME cursor tracking + +On macOS, AI Agent TUIs that hide the hardware cursor can prevent native input-method candidate windows from following the focused pane. Reveal a cursor anchor for those panes with: + +```toml +[experimental] +reveal_hidden_cursor_for_cjk_ime = true +cjk_ime_agents = ["claude", "pi", "codex"] +``` + +Restricting `cjk_ime_agents` avoids showing an extra hardware cursor in unrelated applications. Search these keys in the [Config reference](/docs/config-reference/) for accepted Agent names and cursor shapes. + +## Prefix input source switching + +On macOS and Windows, Herdr can temporarily switch to an ASCII-capable input source while prefix commands and prefix-launched modes are active: + +```toml +[experimental] +switch_ascii_input_source_in_prefix = true +``` + +On macOS this switches to the current ASCII-capable keyboard layout; on Windows it switches the IME to English (ASCII) input. Herdr restores the previous input source when returning to terminal input or entering a text field. This setting has no effect on other platforms. + +:::caution[Windows support is Korean IME only] +On Windows, support is currently limited to the Korean IME. With an IME for any other language, this setting leaves the input source unchanged. +::: + +## Environment variables + +| Variable | Purpose | +| --- | --- | +| `HERDR_CONFIG_PATH` | Override the config file path. | +| `HERDR_SESSION` | Select a named session for CLI commands. | +| `HERDR_SOCKET_PATH` | Low-level socket path override. | +| `HERDR_PROCESS_DETECTION` | Linux process detection strategy: `native` (default) or opt-in `child-groups`. | +| `HERDR_LOG` | Set log filtering, for example `HERDR_LOG=herdr=debug`. | +| `HERDR_DISABLE_SOUND` | Disable sound playback even when `[ui.sound] enabled = true`. | + +## Logs + +Logs are useful when diagnosing startup warnings, integration state, or socket API behavior. + +Common log files: + +```text +~/.config/herdr/herdr.log +~/.config/herdr/herdr-client.log +~/.config/herdr/herdr-server.log +``` + +Logs rotate automatically. Include the current log and rotated siblings when reporting issues. diff --git a/docs/versions/0.9.1/website/src/content/docs/connecting-machines.mdx b/docs/versions/0.9.1/website/src/content/docs/connecting-machines.mdx new file mode 100644 index 00000000..436c2c2a --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/connecting-machines.mdx @@ -0,0 +1,108 @@ +--- +title: Connecting machines +description: Work across Local and saved SSH machines in one Herdr window, with shared agent navigation and independent reconnects. +--- + +Keep your local work and remote agents in one Herdr window. Save an SSH machine once, then switch between its workspaces and Local without opening another client. The agent list includes connected machines, so you can see where work is running and which agent needs an answer. + +Each machine keeps its own Herdr server, sessions, and running processes. A lost connection to one machine does not disconnect the others. + +## Before you connect + +You need normal SSH access to the remote machine. Verify it first: + +```bash +ssh workbox +``` + +`workbox` can be a host from your SSH config. You can also use a target such as `ssh://you@server:2222`. + +Multi-machine connections support Linux, macOS, and Windows clients connecting to Linux or macOS servers on x86_64 or aarch64, or Windows servers on x86_64. Interactive setup can install or update the complete remote package after confirmation; background reconnects only discover installed packages. See [Remote attach over SSH](/docs/persistence-remote/#remote-attach-over-ssh) for SSH configuration, authentication, and custom binaries. + +## Add a machine + +Run setup in an interactive terminal so Herdr can ask before installing or replacing anything: + +```bash +herdr machine add workbox --label "Build machine" +``` + +This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host. + +To use a named session instead, add the optional `--remote-session` flag: + +```bash +herdr machine add workbox --label "Build machine" --remote-session agents +``` + +Herdr checks both the installed binary and the running server. It starts the requested background server before saving the profile. Compatible client and server versions do not have to match. Missing or incompatible installations go through an approval-based setup. When the running server needs replacement, setup asks before stopping it and its pane processes, then starts the compatible server. The default answer is No. If installation and replacement are both needed, one confirmation covers them. `machine add` does not use experimental live handoff. Cancelling or failing setup leaves the profile unsaved. + +Run `herdr` to open the UI. If a local client is already open, added and enabled machines normally appear within a second and connect in the background without changing your selection. An in-progress machine switch finishes before profile changes are applied. The remote server keeps running after setup exits. + +## Switch between machines + +Choose a machine or one of its workspaces in the sidebar. The selected machine receives your pane input and terminal size, and supplies the visible terminal content and graphics. Other connected machines keep updating their workspace information, agent states, and notifications without streaming their pane screens. + +Click the arrow beside any machine to collapse or expand its workspace list without switching away from your current workspace. This also works while that machine is reconnecting. + +For keyboard navigation, press `prefix+w`, then use the workspace navigation keys (Up/Down by default) to highlight workspaces across connected machines in sidebar order. Enter activates the highlighted workspace; Esc or the prefix key cancels without switching. Compact and expanded sidebars reveal the highlighted row, including under a collapsed machine. On desktop, navigation wraps at the ends; the mobile switcher stops at the first or last workspace. Disconnected machines are skipped. + +When highlighting a workspace on another machine, press Enter before using other keyboard actions. This prevents pane, tab, workspace, or custom-command shortcuts from acting on the current machine by mistake. Clicking cancels a remote desktop keyboard preview. Expanded sidebars respect each machine's collapsed worktree groups. + +Local opens immediately on startup without waiting for SSH connections. Selecting Local also cancels an unfinished remote switch without waiting for the remote machine to reply. If Local itself is reconnecting, your selection resumes when it is ready. Local accepts input once its fresh screen and terminal settings are ready. A stalled machine cannot hold up another machine's input. Multiple Herdr clients can also view different tabs on the same server independently; see [Client and server](/docs/concepts/#client-and-server) for shared-tab sizing. + +When a connection is lost, the last workspace and agent state remains visible but dimmed. That is cached information, not live state. Input and navigation into those cached panes stay disabled until a fresh connection and matching screen arrive. Reconnecting never takes selection away from the machine you are using. + +## Rename, disable, or remove + +Read profile IDs from the list rather than deriving them from labels or hostnames: + +```bash +herdr machine list +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +For scripts, add `--json` to `machine list`. + +Renaming changes the displayed label without reconnecting. Disabling keeps the profile for later; removing forgets it. Both disconnect only that machine from the client and leave its remote sessions and agents running, even if the host is unreachable. + +Removing or disabling the machine you are viewing returns you to Local. If Local is unavailable, Herdr shows that and retries its connection instead of selecting a different remote machine. With enabled saved machines, the client can remain usable even if Local fails or restarts. + +## Connection problems + +- **Reconnecting:** Herdr retries automatically after a network interruption, sleep, or SSH failure. Repeated failures increase the delay up to two minutes; brief successful connections do not reset it. A connection must remain healthy for a minute before the next interruption gets a fast retry. SSH connections are checked for application-level activity and probed when quiet, so a broken connection does not stay Online indefinitely. Local detects native connection closure or failure instead of using remote health probes. +- **Attention:** The target needs an action that cannot be completed in the background, such as host-key approval, authentication, or a compatible server. Other machines remain usable. +- **Saved-machine file error:** An unreadable or invalid catalog leaves current connections unchanged. Herdr shows a notice and automatically retries reading it. + +When both installations support bridge idle cleanup, saved-machine connections to Linux and macOS also close their remote bridge after a minute without traffic in either direction. Sleep counts toward that deadline, which is checked when the host wakes. Quiet healthy connections exchange health checks; watching continuous output does not require typing. Cleanup leaves the remote Herdr server, sessions, and pane processes running. Older installations remain compatible without this optional cleanup. + +Background connections never answer prompts or install, update, restart, or hand off a server. For Attention, run the standalone setup command shown by Herdr in an interactive terminal, for example: + +```bash +herdr --remote workbox +``` + +Use your profile's target. If you chose a named session when adding it, include the optional `--session <name>` here too. Follow any approval prompts, then restart the client to retry the Attention connection. Do not stop a running server merely because its version differs from the client. + +If authentication fails, check ordinary SSH first. For a passphrase-protected key, load it with `ssh-add` before starting Herdr's non-interactive background connections. + +## Settings and automation + +The UI uses the client's local theme, sidebar settings, and keybindings by default. Custom commands and plugins advertised by the selected server still run there. Herdr does not copy local command plugins, configuration, executables, or secrets onto SSH hosts. Missing remote commands fail visibly. Use the UI's `reload config` action after editing client settings; see [Configuration](/docs/configuration/#reload-config). + +Default agent rows show a `machine` token when multiple machines are present. Existing custom rows are preserved; add `machine` explicitly if you want that label in your layout. [Sidebar row layouts](/docs/configuration/#sidebar-row-layouts) also support conditional colors for machine labels. + +Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines may both contain `w1:p1` or an agent named `reviewer`. Selecting a machine in the UI does not retarget CLI commands running in an existing pane: they still use that pane's inherited session and socket. For remote automation, use `herdr --machine <label-or-id> agent list`, then pass the same prefix when controlling those remote IDs. The CLI uses the saved profile's SSH target and session directly; it does not need an open TUI. Without `--machine`, existing session/socket routing is unchanged. See [CLI reference](/docs/cli-reference/#saved-ssh-machines) for supported commands, update requirements, and remote path rules. + +## Updates and saved data + +Saved profiles contain only an opaque ID, label, SSH target, explicit remote session, and enabled state. Herdr does not store passwords, private keys, agent tickets, or SSH control sockets in the catalog. Authentication stays with OpenSSH. + +The client and server negotiate compatibility rather than requiring identical versions. Saved-machine connections additionally need the server's `surface_interest` and `health_check` capabilities. Older servers without those capabilities show Attention until explicitly updated, even if a standalone attach works. Other missing server methods disable only their corresponding actions. + +Updating a compatible client does not replace the running remote server or stop its agents. When you need new server-side behavior, update that server explicitly. Normal replacement asks before stopping the server and its pane processes. + +Live handoff is experimental and opt-in. For a supported server that needs replacement during standalone setup, you can explicitly add `--handoff` to `herdr --remote`; it is not needed for normal connections or authentication fixes. See [Update](/docs/install/#update) for restart and handoff choices, and [Session state and restore](/docs/session-state/) for what survives each operation. diff --git a/docs/versions/0.9.1/website/src/content/docs/how-to-work.mdx b/docs/versions/0.9.1/website/src/content/docs/how-to-work.mdx new file mode 100644 index 00000000..f35f8c7e --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/how-to-work.mdx @@ -0,0 +1,103 @@ +--- +title: How to work with Herdr +description: Run Herdr locally, inside SSH, or through remote attach. +--- + +Run Herdr where the work lives. Attach from wherever you are. + +Herdr is a background session server plus one or more terminal clients. Panes keep running in the server. Clients attach, detach, and render the session. + +## Local work + +Start Herdr from the project directory: + +```bash +herdr +``` + +Herdr starts or attaches to your local background session automatically. You do not manage sockets. Run shells, servers, tests, and agents normally inside panes. + +Detach the client with `ctrl+b q`. Your panes keep running. + +Reattach later: + +```bash +herdr +``` + +If you want to end the session and stop its panes, stop the server: + +```bash +herdr server stop +``` + +## Remote work through normal SSH + +SSH to the machine that has the code and credentials, then run Herdr there: + +```bash +ssh you@server +herdr +``` + +This works like a terminal multiplexer. Your shell is remote. The Herdr server is remote. The agents and panes run on the remote machine. Detach with `ctrl+b q`, disconnect, then SSH back and run `herdr` again. + +Use this path when you already live inside an SSH shell, when you are on a phone or tablet SSH client, or when you want the simplest setup. + +## Work from your phone + +Herdr works on your phone without a mobile app or web dashboard. Install any SSH client, connect to the machine where your agents run, and start Herdr there: + +```bash +ssh you@server +herdr +``` + +The same persistent Herdr session opens in your phone terminal. The TUI adapts to narrow screens, so you can inspect agents, switch workspaces, and check panes without leaving SSH. + +On iPhone, apps like [moshi](https://getmoshi.app/) work well. + +<div class="mobile-doc-shots"> + <figure> + <img src="/assets/mobile-agent-session-v2.jpeg" alt="Herdr agent session over SSH on a phone" loading="lazy" /> + <figcaption>agent session over SSH</figcaption> + </figure> + <figure> + <img src="/assets/mobile-switch-menu-v2.jpeg" alt="Herdr responsive switch menu on a phone" loading="lazy" /> + <figcaption>responsive switch menu</figcaption> + </figure> +</div> + +## Remote work from your local terminal + +Attach through SSH without opening a shell first: + +```bash +herdr --remote workbox +herdr --remote ssh://you@server:2222 +``` + +The remote server keeps the panes running and sends their terminal content and session state over SSH. Your local Herdr draws the UI using your local theme and presentation settings. + +Use this path when you want the remote session to feel local. The client runs on your machine, so local desktop features such as image clipboard paste can be bridged to the remote server. If you SSH first and run `herdr` on the server, Herdr runs entirely on that server and cannot read your local desktop clipboard. + +For repeat targets, put the host in your SSH config: + +```text +Host workbox + HostName server.example.com + User you + Port 2222 +``` + +Then attach with: + +```bash +herdr --remote workbox +``` + +## Which path to use + +Use `herdr` for local work. Use `ssh you@server` then `herdr` when you want Herdr to behave like tmux on that remote shell or when you are using a phone SSH client. Use `herdr --remote <host>` for a local UI attached to one remote session, including local clipboard image paste. To keep Local and several SSH machines in one window, save them with `herdr machine add <host> --label <label>`; see [Connecting machines](/docs/connecting-machines/). + +For remote bootstrap details, named remote sessions, custom binaries, and direct terminal attach, see [Persistence and remote access](/docs/persistence-remote/). diff --git a/docs/versions/0.9.1/website/src/content/docs/index.mdx b/docs/versions/0.9.1/website/src/content/docs/index.mdx new file mode 100644 index 00000000..09159fa3 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/index.mdx @@ -0,0 +1,82 @@ +--- +title: Herdr documentation +description: Terminal workspace manager for AI coding agents. +template: splash +hero: + tagline: "Install, learn, and configure Herdr. Start on the path that matches you — no multiplexer experience required." + image: + file: ../../../public/assets/logo.svg + actions: + - text: Install Herdr + link: /docs/install/ + - text: Quick start + link: /docs/quick-start/ + variant: secondary +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + +## Pick your path + +<CardGrid> + <Card title="New to terminal multiplexers?"> + You don't need to learn shortcuts to start. Herdr is mouse-first: click panes, drag borders, split and switch from right-click menus. + + [Quick start →](/docs/quick-start/) + </Card> + <Card title="Coming from tmux or zellij?"> + You already know the model. The prefix is `ctrl+b`, panes persist, detach and reattach work the way you expect. + + [Concepts →](/docs/concepts/) · [Keybindings →](/docs/configuration/#keybindings) + </Card> +</CardGrid> + +## Or let your agent introduce you + +If you already run an AI coding agent, let it handle the onboarding. Paste this prompt: + +```text +Help me understand and set up Herdr. Read https://herdr.dev/agent-guide.md first, then walk me through it step by step. +``` + +The guide covers Herdr's concepts, setup, configuration, and common fixes, so your agent can answer from the docs instead of improvising. + +## Core guides + +<CardGrid> + <Card title="Agents"> + See supported agents, detection behavior, integrations, custom labels, and direct attach. + + [Understand agents →](/docs/agents/) + </Card> + <Card title="Connecting machines"> + Keep Local and saved SSH machines in one window, with a combined agent list and independent reconnects. + + [Connect your machines →](/docs/connecting-machines/) + </Card> + <Card title="Session state"> + Understand detach, restart restore, pane history replay, native agent resume, and live handoff. + + [Compare state paths →](/docs/session-state/) + </Card> + <Card title="Configuration"> + Configure keybindings, themes, sidebar behavior, notifications, scrollback, and advanced options. + + [Configure Herdr →](/docs/configuration/) + </Card> + <Card title="API"> + Control Herdr from scripts, tools, and agents through the CLI and local socket API. + + [Read the API guide →](/docs/socket-api/) + </Card> + <Card title="Plugins"> + Author local executable workflow plugins with manifest actions and event hooks. + + [Write a plugin →](/docs/plugins/) + </Card> + <Card title="Marketplace"> + Share plugins from GitHub today and tag your repo to be listed when the marketplace launches. + + [Publish a plugin →](/docs/marketplace/) + </Card> +</CardGrid> diff --git a/docs/versions/0.9.1/website/src/content/docs/install.mdx b/docs/versions/0.9.1/website/src/content/docs/install.mdx new file mode 100644 index 00000000..c0c422a5 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/install.mdx @@ -0,0 +1,154 @@ +--- +title: Install Herdr +description: Install, update, and verify Herdr on Linux, macOS, and Windows. +--- + +Herdr publishes stable-channel binaries for Linux, macOS, and Windows. Windows is generally available, with documented platform-specific limitations and ongoing fixes. + +## Install + +On Linux or macOS, run: + +```bash +curl -fsSL https://herdr.dev/install.sh | sh +``` + +On Windows, run: + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex" +``` + +If endpoint security blocks that fileless PowerShell command, open Command Prompt and run: + +```cmd +curl.exe -fsSLo install.cmd https://herdr.dev/install.cmd && install.cmd && del install.cmd +``` + +The installer downloads the release binary for your platform and places it on your PATH. New direct installs use the stable update channel. Existing Windows preview installs stay on preview until you switch them with `herdr channel set stable`. On Windows, PATH points to the active versioned release directory; the installer also updates the `current` and stable `bin` compatibility aliases, so updates do not need to overwrite a running `herdr.exe`. + +## Install with Homebrew + +If you already use Homebrew: + +```bash +brew install herdr +``` + +## Install with mise + +If you already use mise: + +```bash +mise use -g herdr +``` + +If mise reports `herdr not found in mise tool registry`, update mise and retry. Older mise versions predate the Herdr registry entry; `mise use -g github:herdrdev/herdr` works as a temporary fallback. + +## Install with Nix + +If you already use Nix, Herdr provides a flake that builds Herdr from source: + +```bash +nix run github:herdrdev/herdr/v0.x.y +nix build github:herdrdev/herdr/v0.x.y +nix profile install github:herdrdev/herdr/v0.x.y +``` + +Replace `v0.x.y` with the latest release tag. You can omit the tag to track `master`, but release tags are recommended for normal installs. + +The flake also exposes a development shell: + +```bash +nix develop github:herdrdev/herdr +``` + +Use the same Nix workflow to update Herdr. For a profile install, list your profile entries and upgrade the Herdr entry: + +```bash +nix profile list +nix profile upgrade <index-or-name> +``` + +If Herdr is an input in your own flake, update that input and rebuild your system, Home Manager, or development environment: + +```bash +nix flake update herdr +``` + +## Download manually + +You can also download a binary from [GitHub releases](https://github.com/herdrdev/herdr/releases). + +Choose the asset that matches your system: + +| System | Asset | +| --- | --- | +| Linux x86_64 | `herdr-linux-x86_64` | +| Linux aarch64 | `herdr-linux-aarch64` | +| macOS Intel | `herdr-macos-x86_64` | +| macOS Apple silicon | `herdr-macos-aarch64` | +| Windows x86_64 | `herdr-windows-x86_64.zip` | + +On Linux or macOS, make it executable and move it somewhere on your PATH. + +```bash +chmod +x herdr-linux-x86_64 +mv herdr-linux-x86_64 ~/.local/bin/herdr +``` + +### Windows archive + +Stable releases and preview prereleases both include `herdr-windows-x86_64.zip`. The archive contains `herdr.exe` and its app-local ConPTY runtime. Keep the extracted directory together; do not copy only `herdr.exe`. + +## Verify + +Start Herdr: + +```bash +herdr +``` + +If your shell cannot find `herdr`, restart the terminal or check that the install directory is on your PATH. + +## Update + +Herdr checks for new releases and notifies you in the app. You can update manually: + +```bash +herdr update +``` + +Use `herdr update` only for installs managed by Herdr's own installer. Update Homebrew, mise, and Nix installs through those package managers instead. + +Direct installs on Linux, macOS, and Windows use the stable update channel by default. To opt into preview builds from `master`, set the channel: + +```bash +herdr channel set preview +``` + +Switch a direct install back to stable the same way: + +```bash +herdr channel set stable +``` + +For direct installs, changing channels checks the selected channel and installs its latest binary. If that update fails, run `herdr update` to retry from the configured channel. + +Preview builds are regularly published GitHub prereleases from the current development branch. They are useful when you want fixes before the next stable release, but they can regress. Homebrew, mise, and Nix installs do not use the preview channel. + +Stable is the recommended channel for normal Windows use. Preview receives fixes sooner but can regress, so opt in only when you want that tradeoff. Existing Windows preview installs remain on preview until you switch them explicitly. If an older preview build rejects `herdr channel set stable`, run `herdr update` once on preview, then retry the channel switch. + +By default, `herdr update` installs the new binary and leaves endpoint-generation-1 servers and their pane processes running. Start Herdr again to reconnect with the updated client. Client-only changes work immediately. A new server-side action stays unavailable until that server is updated and restarted; the client shows a notice instead of disconnecting. Servers from before endpoint generation 1 need one final stop during update. + +To opt into experimental live server handoff for supported running sessions, run: + +```bash +herdr update --handoff +``` + +Live handoff does not apply to Homebrew, mise, or Nix package-manager updates. For those installs, update with the package manager, then run Herdr again to reconnect with the updated client. The compatible old server keeps running. Restart it later with `herdr server stop` or `herdr session stop <name>` only when you want server-side changes from the new release. + +## Requirements + +Stable-channel binaries are available for Linux, macOS, and Windows x86_64. See [Windows support](/docs/windows-beta/) for supported workflows and known limitations. Windows ARM64 runs the x86_64 build under Windows emulation. diff --git a/docs/versions/0.9.1/website/src/content/docs/integrations.mdx b/docs/versions/0.9.1/website/src/content/docs/integrations.mdx new file mode 100644 index 00000000..e584bb06 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/integrations.mdx @@ -0,0 +1,398 @@ +--- +title: Integrations +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. +--- + +Herdr detects supported agents automatically. Install official integrations when you want native agent session restore, direct lifecycle reports, or both. See [Agents](/docs/agents/) for the full status authority model. + +## Install integrations + +Open settings inside Herdr and use the integrations tab to install recommended integrations for agents found on your `PATH`, or run commands manually: + +```bash +herdr integration install pi +herdr integration install omp +herdr integration install claude +herdr integration install codex +herdr integration install copilot +herdr integration install devin +herdr integration install droid +herdr integration install kimi +herdr integration install opencode +herdr integration install kilo +herdr integration install hermes +herdr integration install qodercli +herdr integration install qwen +herdr integration install letta +herdr integration install cursor +herdr integration install mastracode +herdr integration install antigravity-cli +herdr integration install grok +``` + +## Uninstall integrations + +```bash +herdr integration uninstall pi +herdr integration uninstall omp +herdr integration uninstall claude +herdr integration uninstall codex +herdr integration uninstall copilot +herdr integration uninstall devin +herdr integration uninstall droid +herdr integration uninstall kimi +herdr integration uninstall opencode +herdr integration uninstall kilo +herdr integration uninstall hermes +herdr integration uninstall qodercli +herdr integration uninstall qwen +herdr integration uninstall letta +herdr integration uninstall cursor +herdr integration uninstall mastracode +herdr integration uninstall antigravity-cli +herdr integration uninstall grok +``` + +Herdr saves integration configs atomically on Linux/macOS and for new files; existing Windows files keep their permissions through a backup-first, in-place update. If Windows reports a backup, close the agent and inspect the config before retrying: discard—not restore—an unfinished `.herdr-backup.pending` only after verifying the config, or restore completed `.herdr-backup` contents into the existing file if needed (if missing, establish its intended permissions first), then verify the config and remove the backup. + +## How Herdr uses integrations + +Herdr uses integrations in two ways: + +| Integration type | Agents | Effect | +| --- | --- | --- | +| Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. | +| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | + +Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable. + +## Integrate your own agent + +An agent running in a Herdr pane inherits `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_BIN_PATH`, and `HERDR_SOCKET_PATH`. If the agent exposes lifecycle hooks, use those hooks to report semantic state through Herdr's CLI: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source custom:my-agent \ + --agent my-agent \ + --state working +``` + +Report `idle` when the agent is ready for input and `blocked` when it needs a user decision. Use `--message` to describe a block. When the agent exits, release the same source's lifecycle authority: + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source custom:my-agent \ + --agent my-agent +``` + +Report only when `HERDR_ENV=1` and the required variables are present. This keeps the integration a no-op outside Herdr. Keep `--source` stable and unique to the integration. If reports can arrive out of order, include a strictly increasing `--seq`; Herdr ignores stale sequence numbers from the same source. + +You can include `--agent-session-id` or `--agent-session-path` with `report-agent`, or use `pane report-agent-session` when session identity changes independently of state. Herdr exposes that reference through its pane and agent APIs. Automatic session restore also requires Herdr to know how to launch that agent and resume the referenced session. + +Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that needs direct IPC can send the equivalent `pane.report_agent`, `pane.report_agent_session`, and `pane.release_agent` requests described in the [Socket API](/docs/socket-api/#agent-state-reporting). + +[Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world example. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, preserves report ordering across sessions, and releases authority on exit. + +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. + +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. + +## Pi + +Install the Pi integration: + +```bash +herdr integration install pi +``` + +Herdr writes the bundled extension to: + +```text +~/.pi/agent/extensions/herdr-agent-state.ts +``` + +If `PI_CODING_AGENT_DIR` is set, Herdr writes to `$PI_CODING_AGENT_DIR/extensions/herdr-agent-state.ts` instead. Herdr creates the extensions directory when the Pi agent directory already exists. Uninstall removes only that extension file. + +## OMP + +Install the OMP integration: + +```bash +herdr integration install omp +``` + +Herdr writes the bundled extension to: + +```text +~/.omp/agent/extensions/herdr-omp-agent-state.ts +``` + +Herdr uses `PI_CODING_AGENT_DIR` as the complete agent directory when set. Otherwise, it uses `$HOME/$PI_CONFIG_DIR/agent` when `PI_CONFIG_DIR` is set, falling back to `~/.omp/agent`. If Pi and OMP resolve to the same extension directory, Herdr refuses the OMP install so the OMP extension cannot be loaded by Pi. Configure separate agent directories before installing both integrations. Herdr creates the extensions directory when the resolved OMP agent directory already exists. Uninstall removes only that extension file. + +The OMP integration reports `omp` as the agent label, lifecycle state, and native session identity through Herdr's socket API. It does not require native process detection for the `omp` executable, and Herdr can resume an OMP pane with `omp --resume=<session>` after a server restart. + +## Claude Code + +Install the Claude Code hook: + +```bash +herdr integration install claude +``` + +The hook reports Claude Code session identity to the local Herdr socket on session start. Claude Code state comes from Herdr's screen manifest detection. + +Claude integration v10 matches only the documented `SessionStart` sources: `startup`, `resume`, `clear`, `compact`, and `fork`. This prevents Grok's `new` and `load` events from launching the imported Herdr Claude hook, without disabling unrelated Claude-compatible hooks. Reinstall the Claude integration after upgrading to migrate the previous wildcard matcher. New Claude source values require an integration update. + +Herdr uses `~/.claude` by default, or `CLAUDE_CONFIG_DIR` when set. The Claude config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script. + +## Codex + +Install the Codex hook: + +```bash +herdr integration install codex +``` + +The Codex hook reports session identity through the same local socket API used by other integrations. Codex state comes from Herdr's screen manifest detection. + +Herdr uses `~/.codex` by default, or `CODEX_HOME` when set. The Codex config directory must already exist. Install writes `herdr-agent-state.sh`, updates `hooks.json`, and ensures `[features] hooks = true` in `config.toml`. It also removes the deprecated top-level `codex_hooks` flag when present. Uninstall removes Herdr entries from `hooks.json` and deletes the hook script, but leaves `config.toml` unchanged. + +## GitHub Copilot CLI + +Install the GitHub Copilot CLI hook: + +```bash +herdr integration install copilot +``` + +The Copilot hook reports session identity through the same local socket API used by other integrations. Copilot state comes from Herdr's screen manifest detection. + +Herdr uses `~/.copilot` by default, or `COPILOT_HOME` when set. The Copilot config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with a `SessionStart` hook entry. Uninstall removes Herdr entries from `settings.json` and deletes the hook script. + +After Copilot emits a session-bearing event, Herdr can use the reported session id to resume the pane with `copilot --resume=<id>`. + +## Devin CLI + +Install the Devin CLI hook: + +```bash +herdr integration install devin +``` + +The hook reports native session identity from Devin session, prompt, tool-use, permission, and stop events. Devin state still comes from Herdr's screen manifest and OSC detection because Devin hooks do not emit a reliable state transition after every permission cancellation or user interrupt. + +Herdr uses `$XDG_CONFIG_HOME/devin` when `XDG_CONFIG_HOME` is set. Otherwise, it uses `~/.config/devin` on Linux and macOS or `%APPDATA%\devin` on Windows. The Devin config directory must already exist. Install writes `herdr-agent-state.sh` on Unix or `herdr-agent-state.ps1` on Windows and updates `config.json` with Herdr hook entries. The hook refreshes the session reference while Devin runs. Uninstall removes Herdr entries from `config.json` and deletes the hook script. + +Herdr resumes stored Devin sessions with `devin --resume <id>`. Native screen manifest detection remains the state authority whether or not the hook is installed. + +## Kimi Code CLI + +Install the Kimi Code CLI hook: + +```bash +herdr integration install kimi +``` + +The hook reports Kimi session identity and lifecycle state to Herdr for native restore and authoritative `idle`, `working`, and `blocked` status. It requires Kimi Code CLI `0.14.0` or newer. + +Herdr uses `~/.kimi-code` by default, or `KIMI_CODE_HOME` when set. The Kimi Code config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and appends Herdr-managed `[[hooks]]` entries to `config.toml`. Uninstall removes the Herdr-managed config block and deletes the hook script. + +Herdr resumes stored Kimi sessions with `kimi --session <id>`. + +## Droid + +Install the Droid hook: + +```bash +herdr integration install droid +``` + +The Droid hook reports session identity through the same local socket API used by other integrations. Lifecycle state still comes from Herdr's screen manifest detection because Droid hooks do not cover every lifecycle transition. + +Herdr uses `~/.factory` for Droid hooks. The Factory config directory must already exist. Install writes `hooks/herdr-agent-state.sh`, updates `settings.json` with a Herdr `SessionStart` hook entry, and removes older Herdr Droid hook entries from `hooks.json` if present. Uninstall removes Herdr entries from both config files and deletes the hook script. + +After Droid emits a session start event, Herdr can use the reported session id to resume the pane with `droid --resume <id>`. + +## OpenCode + +Install the OpenCode plugin: + +```bash +herdr integration install opencode +``` + +The integration supports OpenCode V1 `1.18.29` or later and OpenCode V2 (tested with beta `19242`). The OpenCode config directory must already exist. Herdr installs the server entrypoint at `~/.config/opencode/plugins/herdr-agent-state.js`, the shared TUI plugin at `herdr-tui-session.js`, and a V2 TUI entrypoint at `herdr-opencode/tui.js` in that config directory. + +Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries. + +V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting. + +The plugin reports lifecycle state and session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. Native screen manifest detection remains available when the plugin is not installed. + +## Kilo Code CLI + +Install the Kilo Code CLI plugin: + +```bash +herdr integration install kilo +``` + +Herdr writes the plugin to `~/.config/kilo/plugin/herdr-agent-state.js`. The Kilo config directory must already exist. Uninstall removes only that plugin file. + +The plugin reports lifecycle state and session identity while Kilo runs inside a Herdr pane. After Kilo emits a session-bearing event, Herdr can use the reported session id to resume the pane with `kilo --session <id>`. Native screen manifest detection remains available when the plugin is not installed. + +## Hermes Agent + +Install the Hermes Agent plugin: + +```bash +herdr integration install hermes +``` + +Herdr writes `plugins/herdr-agent-state/` under the Hermes home directory and enables `herdr-agent-state` in its `config.yaml`. `HERMES_HOME` defaults to `~/.hermes` on Unix and `%LOCALAPPDATA%\hermes` on Windows. The Hermes config directory must already exist. Restart Hermes after installing so the plugin loads. Uninstall removes the plugin directory and removes `herdr-agent-state` from `plugins.enabled`. + +The plugin reports the resumable session id while Hermes runs inside a Herdr pane. Herdr uses screen manifest detection for `working`, `idle`, and `blocked`, and can use the reported session id to resume the pane with `hermes --resume <id>`. + +## Qoder CLI + +Install the Qoder CLI hook: + +```bash +herdr integration install qodercli +``` + +The hook reports Qoder CLI session identity to Herdr for native restore. Lifecycle state still comes from Herdr's screen manifest detection because Qoder hooks do not cover every lifecycle transition. + +Herdr uses `~/.qoder` by default, or `QODER_CONFIG_DIR` when set. The Qoder config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script. + +Herdr resumes stored Qoder CLI sessions with `qodercli --resume <id>`. + +Native screen manifest detection remains available when the hook is not installed. + +## Qwen Code + +Install the Qwen Code hook: + +```bash +herdr integration install qwen +``` + +The `SessionStart` hook reports only Qwen Code's session identity for native restore. Lifecycle state remains under Herdr's screen manifest detection. + +Herdr uses `~/.qwen` by default, or `QWEN_HOME` when set. The Qwen config directory must already exist. Install writes `hooks/herdr-agent-session.sh` (`hooks/herdr-agent-session.ps1` on Windows) and adds a Herdr entry to `settings.json`. Uninstall removes only the matching entry and managed script. + +Herdr resumes stored Qwen Code sessions with `qwen --resume <id>`. + +## Letta Code + +Letta Code integration installation is experimental and CLI-only. It is not listed in the Settings integration screen or the JSON integration API. `herdr integration status` reports it as `letta (experimental)`. Agent detection and `herdr agent start --kind letta` are supported independently of this installation restriction. + +Install the Letta Code hook: + +```bash +herdr integration install letta +``` + +The quiet `SessionStart` hook reports the conversation ID for native restore. Letta Code state still comes from Herdr's screen manifest detection. + +Herdr uses `~/.letta`. The config directory must already exist. Install writes `hooks/herdr-agent-session.sh` (`hooks/herdr-agent-session.ps1` on Windows) and adds a Herdr entry to `settings.json`. Uninstall removes only the matching entry and managed script. + +Herdr resumes named conversations with `letta --conversation <id>`. It records the default conversation as `default:<agent-id>` and resumes it with `letta --conversation default --agent <agent-id>`. + +## Cursor Agent CLI + +Install the Cursor Agent CLI hook: + +```bash +herdr integration install cursor +``` + +The hook reports session identity through Cursor's `sessionStart` hook while Cursor Agent CLI runs inside a Herdr pane. Cursor state comes from Herdr's screen manifest detection. + +Herdr uses `~/.cursor` by default, or `CURSOR_CONFIG_DIR` when set. The Cursor config directory must already exist. Install writes `herdr-agent-state.sh` (`herdr-agent-state.ps1` on Windows) and adds a Herdr `sessionStart` entry to `hooks.json`. Uninstall removes the matching hook entry and deletes the hook script. + +After Cursor emits a session start event, Herdr can use the reported session id to resume the pane with `cursor-agent --resume <id>`. The `cursor-agent` command must be on `PATH` when Herdr restores the pane; Herdr does not launch the generic `agent` command. + +## MastraCode + +Install the MastraCode hook: + +```bash +herdr integration install mastracode +``` + +The hook reports MastraCode lifecycle state and thread identity to Herdr for authoritative `idle`, `working`, and `blocked` status and native restore. MastraCode has no screen manifest fallback; state comes from the hook while MastraCode runs inside a Herdr pane. + +Herdr uses `~/.mastracode`. Install writes `hooks/herdr-agent-state.sh` (`hooks/herdr-agent-state.ps1` on Windows) and adds Herdr command entries to `hooks.json`, creating the directory when missing. Uninstall removes the matching hook entries and deletes the hook script. + +Herdr resumes stored MastraCode threads with `mastracode --thread <id>`. + +## Antigravity CLI + +Install the Antigravity CLI hook: + +```bash +herdr integration install antigravity-cli +``` + +Herdr uses `~/.gemini/config/` by default, or `ANTIGRAVITY_CLI_CONFIG_DIR` when set. This is the directory Antigravity CLI reads global customizations from, and it must already exist. Install writes `hooks/herdr-agent-state.sh` (or `herdr-agent-state.ps1` on Windows) and adds a Herdr-owned `herdr` block to `hooks.json`. Antigravity CLI keys `hooks.json` by hook name, so install rewrites only that block and leaves other named hooks untouched. Uninstall removes the `herdr` block and deletes the hook script. + +This session-only integration reports the pane's current conversation, but not agent state. Herdr keeps deriving working, idle, and blocked from what Antigravity CLI draws on screen. + +The hook runs on `PreInvocation`, so Herdr learns the conversation once the first prompt is sent. From then on Herdr can resume the pane with `agy --conversation <id>` after a Herdr server restart. + +## Grok CLI + +Install the Grok CLI hook: + +```bash +herdr integration install grok +``` + +The hook reports session identity through Grok's `SessionStart` hook while Grok CLI runs inside a Herdr pane. Grok state comes from Herdr's screen manifest detection. + +Herdr uses `~/.grok` by default, or `GROK_HOME` when set. The Grok config directory must already exist. Grok merges every `hooks/*.json` file in that directory, so install writes a self-contained `hooks/herdr.json` with the Herdr `SessionStart` entry next to `hooks/herdr-agent-state.sh` (`hooks/herdr-agent-state.ps1` on Windows), and never edits other hook files. Uninstall removes exactly those two Herdr-owned files. + +After Grok emits a session start event, Herdr can use the reported session id to resume the pane with `grok --resume <id>`. + +## Custom status labels + +Integrations report lifecycle state as semantic state only. For example, report an agent as `working` without adding display fields to the lifecycle report. + +```bash +herdr pane report-agent w1:p1 \ + --source custom:docs \ + --agent docs-bot \ + --state working +``` + +User hooks that run next to a Herdr-managed integration should use metadata instead of `report-agent`. Metadata changes presentation without taking over the integration's `idle`, `working`, `blocked`, or session restore authority. `--agent` and `--applies-to-source` guard only presentation fields (`--title`, `--display-agent`, and `--state-label`). Token patches always apply; their reporter owns clearing or TTL refresh. `--display-agent` changes the visible name. + +```bash +herdr pane report-metadata "$HERDR_PANE_ID" \ + --source user:claude-title \ + --agent claude \ + --title "Refactor auth middleware" \ + --display-agent "Claude: auth" \ + --token summary="refactor auth" \ + --state-label working="refactoring auth" \ + --ttl-ms 3600000 +``` + +Tokens and state labels are visual-only. Waits, notifications, and workspace rollups still use the semantic state. + +## Debug integration state + +List known agents: + +```bash +herdr agent list +``` + +Read a pane when you need to verify what Herdr can see: + +```bash +herdr pane read w1:p1 --source recent --lines 50 +``` + +If integration state looks wrong, first confirm the agent is running inside Herdr and that the relevant hook or plugin was installed for the same user account. diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/agent-automation.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/agent-automation.mdx new file mode 100644 index 00000000..f69d6a3a --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/agent-automation.mdx @@ -0,0 +1,125 @@ +--- +title: エージェント自動化 +description: Herdr のレイアウト、ペイン、エージェントの各プリミティブを使い、スクリプトや別のエージェントからコーディングエージェントを連携させます。 +--- + +Herdr はコーディングエージェントの自動化レイヤーとして使えます。スクリプトから制御することも、ひとつのエージェントから別のエージェントへ作業を渡し、状態を確認して結果を集めることもできます。重要なのは、目的に合うプリミティブを選ぶことです。 + +## 3 つのプリミティブ + +| プリミティブ | 責務 | +| --- | --- | +| レイアウト(`workspace`、`tab`、ペイン構成) | ターミナルの場所を作成し、整理する。 | +| ペイン | 生のターミナルを制御する。コマンド実行、入力送信、出力読み取り、出力待機を行う。 | +| エージェント | 認識済みコーディングエージェントを、名前またはペインとライフサイクル状態に基づいて制御する。 | + +ペインは、エージェントが入っていなくても存在します。エージェントは、ペイン内で現在実行されていると Herdr が認識したプロセスです。そのため `agent start` には既存のシェルペインが必要で、レイアウトの作成、分割、移動は行いません。 + +ワークスペースを作成すると最初のタブとルートペインも作られ、タブを作成するとそのルートペインも作られます。最初のプロセスには返されたペイン ID を使い、そのレイアウトに別のターミナルが必要なときだけ分割します。 + +作成コマンドは JSON を出力します。ID を推測せず、レスポンスから取得してください: + +```bash +created=$(herdr workspace create --cwd ~/project --label api --no-focus) +pane_id=$(printf '%s\n' "$created" | jq -r '.result.root_pane.pane_id') + +split=$(herdr pane split "$pane_id" --direction right --no-focus) +review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') +``` + +`workspace create` は `.result.workspace`、`.result.tab`、`.result.root_pane` を返します。`tab create` は `.result.tab` と `.result.root_pane` を返し、`pane split` は新しいペインを `.result.pane` として返します。 + +ペインを別のワークスペースへ移動すると、ワークスペース修飾付きのペイン ID が変わります。`pane move` の後は `.result.move_result.pane.pane_id` を使い、以前の値が必要なら `.result.move_result.previous_pane_id` を参照してください。実行中プロセスは起動時の Herdr 環境を保ちますが、以前の `HERDR_PANE_ID` はそのターミナルの別名として残るため、`--current` は引き続き安全に使えます。移動後に始めるコマンドはエージェント名で解決できますが、すでに進行中の待機は `agent_not_running` で終了します。 + +シェル、テスト、サーバー、CI ウォッチャーなどの通常のターミナルプロセスにはペインコマンドを使います。どのエージェントが動いているか、または状態が `working`、`blocked`、`done`、`idle`、`unknown` のどれかを Herdr に判断させる場合はエージェントコマンドを使います。 + +## エージェントの識別子と起動 + +`w1:p2` のようなペイン ID はターミナルの場所を識別します。`reviewer` のようなエージェント名は、そのペインに現在いるエージェントの扱いやすい別名です。名前は `[a-z][a-z0-9_-]{0,31}` に一致し、ライブエージェント間で一意でなければなりません。エージェントが終了、解放、または置換されると別名は消えます。ペイン自体の恒久的な名前ではありません。 + +エージェントコマンドは、一意なライブ名、または現在そのエージェントをホストしているペイン ID のどちらでも指定できます。 + +利用可能なシェルペインとは、対話シェルのプロンプトに戻っており、フォアグラウンドをシェル自身が所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていないペインです。`agent start` の前にプロンプトへ戻してください。 + +`--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki`、`muse` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 + +成功した `agent start` は、同じターミナルで期待したエージェントを検出し、対話入力の準備ができたと Herdr が判断してから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。起動待機はデフォルトで 30 秒です。`--timeout` は 3000 より大きく 300000 以下のミリ秒で指定します。 + +```bash +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +``` + +手動で起動したエージェントも自動検出され、ペイン ID で指定できます。人が読みやすい安定した対象名が必要なら名前を付けます: + +```bash +herdr agent get w1:p2 +herdr agent rename w1:p2 reviewer +``` + +## 操作面を選ぶ + +| 目的 | コマンド | +| --- | --- | +| シェルコマンドを実行して送信する | `pane run` | +| Enter を付けずに文字列を送る | `pane send-text` | +| ターミナルキーや修飾キーの組み合わせを送る | `pane send-keys` | +| 文字列や正規表現を待つ | `pane wait-output` | +| 既存のペインで対応済みエージェントを起動する | `agent start` | +| プロンプトを送信し、必要なら完了を待つ | `agent prompt` | +| エージェントの対話 UI にキーを送る | `agent send-keys` | +| エージェントのライフサイクル状態を待つ | `agent wait` | + +`agent prompt` はテキストを送信し、短い遅延の後にエンコード済み Enter を送信します。ターミナルの現在の bracketed paste モードを尊重します。working 中のエージェントにも送信できます。エージェントがすでに `blocked` の場合は、ターミナル入力を送信せずに `agent_blocked` を返します。ダイアログを確認し、意図した応答には `agent send-keys` を使ってください。`esc`、`up`、`enter`、`ctrl+c` のような操作には `agent send-keys` を使います。`escape` も `esc` のエイリアスとして使えます。意図的に生のターミナルを操作したい場合は、ペイン入力コマンドを使ってください。 + +ペイン入力は、現在のプロセスに関係なくターミナルを指定します。エージェント入力はライブエージェントを解決し、そのエージェントがペインを制御しなくなっていれば操作を拒否します。 + +`agent prompt --wait` は、エージェントがすでに `blocked` の場合、入力も待機も開始せずに `agent_blocked` を返します。それ以外は、プロンプトと遅延した Enter を順序付きの 1 回の送信として書き込んでから待機します。Windows の Codex では Enter の前にペースト境界が送られるため、送信はプロンプトのサイズに依存しません。呼び出し側のタイムアウトには送信時間も含まれます。別の non-working 状態から始まった場合、送信後に最大 5 秒間、`working` または `blocked` を観測するまで待ちます。観測できなければ `agent_prompt_stalled` を返し、呼び出し側のタイムアウトが先に切れた場合は通常の `timeout` エラーを返します。これにより、無関係な `idle`、`done`、またはセッションの変化によって待機が誤って完了することを防ぎます。活動を観測した後、要求された安定状態を待ちます。個々のターンは追跡しません。エージェントがすでに working の場合、進行中ターンの完了が待機を満たすことがあります。単独の `agent wait` は現在のエージェントを監視し、すでに状態が一致していれば即座に返ります。どちらもデフォルトでは `idle`、`done`、`blocked` を待ちます。複数の正確な状態を許可するには、`--until idle --until done` のように `--until` を繰り返します。`unknown` が必要な場合は `--until unknown` を明示してください。`agent prompt` では `--until` に `--wait` が必要です。 + +`idle` と `done` はどちらも入力可能な状態です。CLI/API はサーバーの既読状態を使い、未確認の idle を `done` とします。明示的な `pane focus` / `agent focus` は対象を既読にしますが、読み取りでは変わりません。各 TUI クライアントは表示済みの完了を独立して管理するため、Done バッジが CLI やほかのクライアントと異なる場合があります。`blocked` は承認または質問 UI を Herdr が認識した状態です。`unknown` はエージェントが存在するもののライフサイクルを確実に分類できない状態で、成功完了を意味しません。違いが重要なら正確な `--until` を指定してください。 + +タイムアウトや `agent_prompt_stalled` は、入力が送られていないことを保証しません。二重送信を避けるため、再試行の前にエージェントを読み取ってください。ID とエージェント名はサーバーごとに独立しています。TUI で別のマシンを選んでも、既存ペイン内の CLI コマンドの接続先は変わりません。 + +`pane wait-output` はエージェントのライフサイクルを解釈しません。選択したターミナルスナップショットをポーリングし、最初にすぐ検索するため、すでに存在する文字列も一致します。デフォルトのソース名は `recent` で、直近 80 行の描画済みターミナル行を折り返し前の出力として扱います。`--lines` でその行数を変更でき、`--regex` は Rust の正規表現構文で 1 行ずつ一致します。 + +CLI では、`pane read` と `agent read` のどちらもターミナルテキストを直接出力します。デフォルトは ANSI エスケープを除去した UTF-8 テキストです。ソースに装飾が含まれる場合、`--format ansi` または `--ansi` でエスケープを残します。`detection` ソースは常にプレーンテキストです。recent 系ソースでは、`--lines N` は折り返し解除の前に末尾の N 描画行を選び、省略時は 80 行です。`visible` と `detection` では、省略時にスナップショット全体を返し、指定時は改行で区切られた末尾 N 行を返します。socket API ではテキストを `.result.read.text` に返します。 + +## 代替画面の履歴読み取り + +Claude Code や OpenCode などの全画面エージェントは、トランスクリプト履歴を Herdr のホスト側スクロールバックではなく、ターミナルの代替画面に描画します。認識済みのエージェントが idle でトランスクリプトの末尾にあり、`--lines` が表示画面より多い行を要求した場合、`recent` または `recent-unwrapped` のテキスト読み取りはエージェントのマウススクロール操作を自動的に使います。Herdr は重なり合うページを収集し、読み取りが完了する前にビューポートを末尾へ戻します。そのペインに対象エージェントがいる場合、`pane read` にも同じ動作が追加オプションなしで適用されます。 + +その他の読み取りは受動的なままです。`visible`、`detection`、ANSI 読み取り、出力待機と購読、手動でスクロールされたエージェント、直接アタッチ中のペイン、マウスホイール入力を報告しないアプリケーションでは、Herdr はアプリケーションのビューポートを動かしません。明示的な `agent read --lines N` が代替画面の履歴を必要とする場合、エージェントが working、blocked、または unknown の間は `agent_not_idle` を返します。idle になるまで待って再試行するか、`--source visible` を使ってください。その他の recent 読み取りは従来どおり、利用可能な画面とホスト側スクロールバックを返します。 + +完全な応答を取得できない場合は、一時ディレクトリの Markdown ファイルに応答を書き込み、ファイルパスだけを返すようエージェントに依頼して、そのファイルを直接読み取ってください。 + +成功した `agent start`、`agent prompt`、`agent wait` は現在のエージェントを `.result.agent` に返します。`pane wait-output` は `.result.pane_id`、`.result.matched_line`、一致したスナップショット `.result.read` を返します。 + +wait コマンドにはデフォルトのタイムアウトがなく、無期限に待機できます。タイムアウトやサーバーエラーでは JSON エラーを stderr に出して終了コード 1、CLI 構文エラーでは終了コード 2 を返します。 + +## レシピ + +補助エージェントを起動して作業を渡し、その作業が落ち着くまで待ちます: + +```bash +split=$(herdr pane split --current --direction right --no-focus) +review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +herdr agent prompt reviewer "Review the current diff" --wait --timeout 120000 +herdr agent read reviewer --source recent-unwrapped --lines 120 +``` + +エージェントが入力を求めるまで待ち、内容を確認して対話 UI を操作します: + +```bash +herdr agent wait reviewer --until blocked --timeout 120000 +herdr agent read reviewer --source recent-unwrapped --lines 80 +herdr agent send-keys reviewer esc +``` + +通常のプロセスを実行し、エージェントとして扱わずに出力を待ちます: + +```bash +herdr pane run w1:p3 "just test --watch" +herdr pane wait-output w1:p3 --regex "passed|failed" --timeout 120000 +``` + +コマンドとオプションの完全な一覧は [CLI リファレンス](/ja/docs/cli-reference/) を参照してください。シェル補完でも同じコマンドツリーを対話的に確認できます。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/agent-skill.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/agent-skill.mdx new file mode 100644 index 00000000..15120dfa --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/agent-skill.mdx @@ -0,0 +1,67 @@ +--- +title: エージェントスキルファイル +description: Claude Code などのコーディングエージェントに Herdr の使い方をインストールします。 +--- + +Herdr は再利用可能なエージェントスキルファイルを [`skills/herdr/SKILL.md`](https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md) として提供しています。 + +このファイルを、再利用可能なスキルやカスタム指示に対応した任意のコーディングエージェントにインストールしてください。このスキルは、Herdr のペイン内から Herdr を制御する方法をエージェントに教えます。 + +Herdr は別の用途向けに [`herdr.dev/agent-guide.md`](https://herdr.dev/agent-guide.md) というガイドも提供しています。こちらは、人間が Herdr を学習・セットアップ・トラブルシューティングするのをエージェントが手伝うためのものです。スキルは Herdr を操作するエージェントのため、ガイドは人間に教えるエージェントのためのものです。 + +## スキルがすること + +このスキルは、`HERDR_ENV=1` が設定されているときに `herdr` CLI を使うようエージェントに指示します。これは、エージェントが Herdr 管理下のペイン内で動作しており、ローカルの Herdr ソケットと安全に通信できることを意味します。 + +スキルをインストールすると、エージェントは次のことができます: + +- ワークスペース、タブ、ペイン、隣のエージェントを調べる +- フォーカスを奪わずにペインを分割してコマンドを実行する +- ペインの出力と最近のログを読む +- サーバー、テスト、別のエージェントの完了を待つ +- 隣のペインでヘルパーエージェントを起動する + +このスキルは独立したアプリやサービスではありません。エージェント向けの markdown 指示ファイルです。Herdr がすでにインストールされている場合は、`herdr --skill` を実行すると、そのバイナリのリリースと一致する同梱コピーを表示できます。 + +## インストールする + +`npx skills` でスキルをインストールします: + +```bash +npx skills add herdrdev/herdr --skill herdr -g +``` + +スキルがリポジトリのルートにあったときにインストールした場合は、`skills update` ではなく、この add コマンドを一度再実行してください。大きすぎる既存のコピーが置き換えられ、新しい場所が記録されます。 + +`-g` フラグは、対応エージェントにグローバルインストールします。現在のプロジェクトにインストールする場合は `-g` を省略してください。 + +手動でのフォールバックおよび信頼できるソースとしては、リポジトリのコピーを使ってください: + +```text +https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md +``` + +スキルシステムを持つエージェントには、このファイルを `herdr` という名前のスキルとしてインストールしてください。スキルシステムを持たないエージェントには、ファイルの内容をプロジェクト指示またはユーザー指示に貼り付けてください。 + +インストール後、Herdr の中でエージェントを起動します: + +```bash +herdr +claude +``` + +他のコーディングエージェントを Herdr のペインで使っても構いません。重要なのは、エージェントのプロセスが Herdr 内で動作していて `HERDR_ENV=1` が利用できることです。 + +## 安全ルール + +このスキルはひとつのガードレールから始まります: `HERDR_ENV=1` が設定されていない場合、エージェントは停止して、Herdr 管理下のペイン内で動作していないと伝えるべきです。 + +これにより、Herdr の外にいるエージェントが自分のものではないセッションを制御しようとするのを防ぎます。 + +## エージェント向けリファレンス + +コマンドの完全なガイドはスキルファイル自体にあります。ペイン ID、`pane split`、`pane run`、`pane read`、`pane wait-output`、`agent wait`、ワークスペースとタブのコマンド、協調動作のレシピを扱っています。 + +ソースファイルはこちら: + +[GitHub で `SKILL.md` を開く →](https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md) diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/agents.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/agents.mdx new file mode 100644 index 00000000..3a365221 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/agents.mdx @@ -0,0 +1,159 @@ +--- +title: エージェント +description: Herdr が何を検出できるか、エージェント状態の仕組み、インテグレーションによる精度向上。 +--- + +Herdr は複数のコーディングエージェントを同時に動かすために作られています。各エージェントは、シェル、ログ、プロンプト、実行中プロセスをそのまま保った実際のターミナルペインの中にいます。Herdr はどのペインにエージェントがいるかを追跡し、その状態をタブとワークスペースに集約し、すべてのターミナルを手作業で見回る代わりに、注意が必要なペインへ直接ジャンプできるようにします。 + +スクリプトや別のエージェントから連携させる方法は、[エージェント自動化](/ja/docs/agent-automation/) を参照してください。 + +## 対応エージェント + +一般的なコーディングエージェントは、追加設定なしで自動検出されます。重要な違いは Herdr がエージェントを見えるかどうかではありません。どのシグナルが `idle`、`working`、`blocked` を決定する権限を持つかです。 + +| エージェント | 状態の権威 | インテグレーションの役割 | +| --- | --- | --- | +| Pi | インストール時はライフサイクルフック。それ以外はスクリーンマニフェスト | 状態とセッション | +| OMP | インストール時はライフサイクルフック | 状態とセッション | +| GitHub Copilot CLI | スクリーンマニフェスト | セッション | +| Devin CLI | スクリーンマニフェスト | セッション | +| Kimi Code CLI | インストール時はライフサイクルフック。それ以外はスクリーンマニフェスト | 状態とセッション | +| Hermes Agent | スクリーンマニフェスト | セッション | +| Qoder CLI | スクリーンマニフェスト | セッション | +| Qwen Code | スクリーンマニフェスト | セッション | +| Letta Code | スクリーンマニフェスト | セッション | +| Droid | スクリーンマニフェスト | セッション | +| OpenCode | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | +| Kilo Code CLI | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | +| MastraCode | インストール時はライフサイクルフック | 状態とセッション | +| Claude Code | スクリーンマニフェスト | セッション | +| Codex | スクリーンマニフェスト | セッション | +| Cursor Agent CLI | スクリーンマニフェスト | セッション | +| Amp | スクリーンマニフェスト | なし | +| Grok CLI | スクリーンマニフェスト | セッション | +| Antigravity CLI | スクリーンマニフェスト | セッション | +| Kiro CLI | スクリーンマニフェスト | なし | +| Maki | スクリーンマニフェスト | なし | +| Muse | スクリーンマニフェスト | なし | + +検出されるもののテストが薄いもの: Gemini CLI と Cline。未対応のエージェントも通常のターミナルプロセスとして問題なく動きます。ただし、インテグレーションを追加するかソケット API で状態を報告しない限り、詳細な状態は得られない可能性があります。 + +## 状態の権威 + +Herdr はまず各ペインのフォアグラウンドプロセスを検出します。その後、各ペインはひとつの状態権威を持ちます。 + +完全なライフサイクルフックを持つエージェントでは、インテグレーションがインストールされ、実行中のペインについて能動的に報告している間は、インテグレーションが権威です。Herdr はそのフック報告を `idle`、`working`、`blocked` とセッション識別に使います。同じライフサイクル権威に対してスクリーンマニフェストのフォールバックを並走させることはしません。これにより、真実の情報源が 2 つ競合する状況を避けます。 + +完全なライフサイクルフックを持たないエージェントでは、Herdr はフォアグラウンドプロセスを識別し、ライブの下部バッファのスクリーンスナップショットを読みます。そのスナップショットに対して TOML マニフェストを評価し、`idle`、`working`、`blocked` を分類します。それらを発するエージェントでは、マニフェストはターミナルタイトルと進捗 (OSC) シーケンスも検出の証拠としてマッチできます。その証拠がない場合は、スクリーンルールが単独で検出を担います。 + +スクリーンスナップショットは、スクロールされたビューポートではなく、ペインバッファの直近の下部から取得されます。Herdr でスクロールバックしても、検出は下部のライブなエージェント UI を追い続けます。 + +上の表で「セッション」と記載されたインテグレーションは、意図的にライフサイクル権威にしていません。これらは復元のためのネイティブセッション識別を提供しますが、フックがライフサイクル全体をカバーしていません。許可承認の結果、Esc による中断、その他の遷移を見逃すことがあります。これらのエージェントでは、Herdr は引き続きスクリーンマニフェスト検出を使います。 + +## VM とサンドボックスラッパー + +Linux と macOS では、ホストから見えるラッパーが実際のエージェントプロセスを Herdr から隠すことがあります。ラッパーのコマンドに `HERDR_AGENT=<agent>` を設定して、どの既存エージェントのスクリーンマニフェストを使うべきか Herdr に伝えてください。たとえば、Linux では `HERDR_AGENT=claude fence -- claude`、macOS では `HERDR_AGENT=claude nono run --profile claude-code -- claude` を実行します。このヒントはそのフォアグラウンドプロセスにスコープされます。VM やコンテナの内部だけで設定しても Herdr からは見えません。また、継承されるすべてのフォアグラウンドプロセスをそのエージェントとして扱いたいのでない限り、グローバルな export は避けてください。 + +一部の制限された Linux ランタイムでは、ターミナルのフォアグラウンドプロセスグループが公開されません。ネイティブ検出が利用できない場合に直接の子プロセスグループ推論を有効にするには、Herdr サーバーを `HERDR_PROCESS_DETECTION=child-groups` 付きで起動します。ネイティブ検出が引き続き優先され、デフォルトの `native` モードではこの推論を行いません。このオプトインモードはベストエフォートで、新しいバックグラウンドジョブをフォアグラウンドと誤認する可能性があります。この変数はサーバーが読み取るため再起動が必要です。アタッチするクライアントではなく、リモートサーバーの環境に設定してください。 + +## blocked 状態 + +スクリーンマニフェスト方式のエージェントでは、blocked の検出は意図的に厳格です。Herdr が `blocked` と判定するのは、ライブの下部バッファスナップショットが既知の承認・質問・許可 UI にマッチしたときだけです。既知のエージェントでどのマニフェストルールにもマッチしない場合、Herdr は `idle` にフォールバックし、explain の出力ではそのフォールバックに `default_known_agent_idle_fallback` というラベルを付けます。 + +つまり、見慣れない新しいエージェントプロンプトは、Herdr がその画面の形を学習するまで、最初は `blocked` ではなく `idle` と表示されることがあります。こうしたやり取りによって Herdr が入力を送ったり破壊的な操作をしたりすることはありません。影響するのは表示上の状態と wait だけです。 + +## 検出マニフェスト + +バンドルされたマニフェストは Herdr の内部にあります。Herdr は herdr.dev でリモートマニフェストの更新も確認し、有効なエージェント別ルール更新を Herdr の再起動なしで自動適用します。リモートマニフェストは Herdr の state ディレクトリに保存されます。バックグラウンドのリモートマニフェスト確認を無効にするには `[update] manifest_check = false` を設定します。 + +ローカルオーバーライドは、プラットフォームの設定ディレクトリからリモートまたはバンドルのマニフェストを置き換えられます: + +```text +~/.config/herdr/agent-detection/<agent>.toml +``` + +ローカルオーバーライドが常に優先されます。ローカルオーバーライドがない場合、Herdr はキャッシュされたリモートマニフェストと実行中バイナリにバンドルされたマニフェストのうち、新しくて互換性のある方を使います。デバッグビルドでは、同じ設定ヘルパーが `herdr-dev` のような開発用ディレクトリを使うことがあります。無効なオーバーライドファイルは警告付きで無視され、Herdr はそのエージェントについてキャッシュされたリモートまたはバンドルのマニフェストにフォールバックします。 + +リモートマニフェストは、Herdr がすでに識別方法を知っているエージェントの検出ルールにパッチを当てるものです。完全に新しいエージェントの追加には、プロセス検出、ラベル、インテグレーション挙動のために Herdr バイナリのアップデートが引き続き必要です。 + +実行中のサーバーは起動時にアクティブなマニフェストをメモリに読み込みます。リモートマニフェストの自動更新は、新しいルールが書き込まれた後にそのメモリ内キャッシュをリロードします。`herdr server update-agent-manifests` を実行すると、リモートマニフェストの更新を即座に取得して実行中のサーバーをリロードします。ローカルオーバーライドを手で編集した後は、Herdr を再起動するか `herdr server reload-agent-manifests` を実行して、実行中のサーバーにファイルを適用してください。 + +ペインの状態表示がおかしいときは `herdr agent explain` を使ってください: + +```bash +herdr agent explain <target> +herdr agent explain --file screen.txt --agent codex --json +``` + +ライブの explain は実行中のサーバーが評価するので、アクティブなマニフェストキャッシュを反映します。explain の出力には次が表示されます: エージェント、最終状態、完全なライフサイクル権威によってスクリーン検出がスキップされたかどうか、マニフェストのソースとバージョン、キャッシュされたリモートバージョン、ローカルオーバーライドによるシャドーイング、リモート更新の状況、マッチしたルール、可視の証拠フラグ、評価されたルールのマッチャーとリージョンの証拠、トランスクリプトビューアーでの更新スキップ理由、そしてどのルールにもマッチしなかったときの idle フォールバック理由です。 + +Herdr は外側のターミナル環境として tmux の中で動かせます。エージェント検出は、Herdr のペイン内で起動された tmux セッションの中までは調べません。シェルフレームワークが Herdr 内で自動的に tmux に入る場合、Herdr はペインのプロセスとして背後のエージェントではなく `tmux` を見ることになります。 + +## 状態のロールアップ + +サイドバーは状態を上位へ集約します。 + +blocked なエージェントは、そのペイン、タブ、ワークスペースを blocked に見せます。working なエージェントはワークスペースをアクティブに見せます。done なエージェントは、あなたが確認するまで表示され続けます。 + +これが Herdr の中心的なワークフローです: 複数のエージェントを起動し、並行して働かせ、サイドバーでどのプロジェクトが判断を必要としているか、どれがまだ実行中か、どれがレビュー待ちかを把握します。 + +## ダイレクトインテグレーション + +使っている各エージェントのインテグレーションをインストールしてください。スクリーン検出だけに頼らず、フックやプラグインの報告を Herdr に提供します: + +```bash +herdr integration install claude +herdr integration status +``` + +対応エージェントごとに、インテグレーションの名前と挙動は異なります。エージェント別の詳細と完全なインストール一覧は[インテグレーション](/ja/docs/integrations/)を参照してください。エージェントを開発している場合は、[カスタムインテグレーションガイド](/ja/docs/integrations/#独自エージェントを統合する)で、Herdr にネイティブサポートを追加せずにライフサイクル状態を報告する方法を確認できます。 + +## カスタムエージェントラベル + +表示用にエージェントターゲットの名前を変えられます: + +```bash +herdr agent rename w1:p1 reviewer +herdr agent rename reviewer --clear +``` + +ターゲットには一意なライブエージェント名、または現在そのエージェントをホストしているペイン ID を使います。ターミナル ID とエージェント kind のラベルだけでは指定できません。 + +## カスタムステータスラベル + +インテグレーションは、ライフサイクル状態を意味的な状態としてのみ報告します。表示のカスタマイズは、ペインメタデータのトークンとして別に追加します。 + +```bash +herdr pane report-agent w1:p1 \ + --source custom:indexer \ + --agent docs-bot \ + --state working + +herdr pane report-metadata w1:p1 \ + --source custom:indexer-display \ + --token summary=indexing +``` + +`state` は wait、通知、ロールアップを制御します。`summary` トークンは表示専用で、エージェントサイドバー行では `$summary` として使えます。 + +エージェントサイドバー行では、`terminal_title` または `terminal_title_stripped` も任意で使えます。どちらもデフォルト行には含まれません。前者は安全性のために正規化された最新の OSC 0/2 ターミナルタイトルを表示し、後者は先頭にある認識済みのアクティビティまたはスピナーのグリフ 1 つと、それに続く空白を除去します。これらの値は Herdr サーバーが所有し、コールドリスタートをまたいで保持されません。また、メタデータのタイトルや意味的なエージェント状態から独立しています。そのため、除去後のテキストが同じなら、スピナーのアニメーションで生のタイトルが変わってもペイン更新は発行されません。 + +## エージェントに直接アタッチする + +完全な Herdr UI ではなく、ひとつのエージェントターミナルに現在のターミナルをアタッチします: + +```bash +herdr agent attach reviewer +``` + +`ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 + +マウスホイールまたは通常の page up/page down でスクロールします。通常の入力をすると最下部に戻ります。 + +別のダイレクトアタッチクライアントがすでに入力を所有している場合は `--takeover` を使います: + +```bash +herdr agent attach reviewer --takeover +``` + +エージェントではないターミナルで同じダイレクトアタッチ挙動が欲しいときは `herdr terminal attach <terminal_id>` を使ってください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/cli-reference.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/cli-reference.mdx new file mode 100644 index 00000000..fbda5674 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/cli-reference.mdx @@ -0,0 +1,495 @@ +--- +title: CLI リファレンス +description: セッション、ワークスペース、タブ、ペイン、通知、エージェント、wait、インテグレーション、ステータスのための Herdr コマンド。 +--- + +Herdr の CLI は、インテグレーションやエージェントが使うのと同じローカルソケット API を通じて、実行中のサーバーと通信します。 + +ほとんどのコマンドは JSON レスポンスを出力します。決定的な自動化が欲しいときはスクリプトから使ってください。 + +## 起動とステータス + +```bash +herdr # デフォルトセッションを起動またはアタッチ +herdr --remote workbox # SSH 越しにアタッチ (ローカルのキーバインドを使用) +herdr --default-config # デフォルト設定を表示 +herdr update # 設定済みチャンネルからダウンロードしてインストール +herdr completion zsh # zsh 補完スクリプトを生成 +herdr channel show # stable または preview を表示 +herdr channel set preview # プレビュービルドにオプトイン +herdr channel set stable # 直接インストールを安定版に戻す +herdr --version # バージョンを表示 +``` + +起動と更新の任意オプション: + +- デフォルトではなく名前付きセッションを使う場合は `--session <name>` を追加します。 +- リモート接続でサーバーのキーバインドを使う場合は `--remote-keybindings server` を追加します。 +- 実験的なライブハンドオフには、`herdr --remote` または `herdr update` で明示的な `--handoff` が必要です。通常のセットアップや接続には使いません。[更新](/ja/docs/install/)を参照してください。 + +ステータスコマンド: + +```bash +herdr status +herdr status server +herdr status client +``` + +API スキーマコマンド: + +```bash +herdr api schema +herdr api schema --json +herdr api schema --output herdr-api.schema.json +``` + +`herdr api schema` は、インストール済みバイナリに同梱されたソケットプロトコルスキーマの短い概要を表示します。完全な JSON Schema ドキュメントが欲しいときは `--json` を使い、ファイルに書き出すには `--output PATH` を使ってください。 + +## 保存済み SSH マシン + +```bash +herdr machine list +herdr machine add workbox --label "Build machine" +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +`machine add` はリモートのデフォルトセッションを使います。名前付きセッションを使う場合だけ `--remote-session <name>` を追加します。スクリプトでは `machine list` に `--json` を追加できます。詳しくは[マシンへの接続](/ja/docs/connecting-machines/)を参照してください。 + +`machine add` はリモート側の Herdr が必要な機能を備えているか確認し、必要な場合だけ承認を得てインストールまたは更新します。指定したセッションのバックグラウンドサーバーを起動してからプロファイルを保存します。互換性があればリリースバージョンは一致しなくてもかまいません。インストールや再起動の承認が必要な場合は、対話型ターミナルで実行してください。セットアップが失敗またはキャンセルされた場合、プロファイルは保存されません。実行中サーバーの置き換えには明示的な承認が必要で、デフォルトの回答は No です。停止するとペインのプロセスも終了します。`machine add` は実験的なハンドオフを暗黙に有効にしません。 + +変更は、開いているローカルの Herdr クライアントに通常 1 秒以内に自動反映されます。追加または有効化したマシンにはバックグラウンドで接続し、名前の変更では再接続しません。削除または無効化すると、そのマシンだけが切断され、リモートセッションは動き続けます。表示中のマシンを削除すると Local に戻ります。Local に接続できない場合は、再接続するまで利用不可と表示します。各プロファイルは、不透明な ID、ラベル、SSH 接続先、明示的なリモートセッション名、有効状態をクライアントの状態ファイルに保存します。パスワード、秘密鍵、その他の SSH 認証情報は保存しません。 + +自動接続と再接続は非対話型です。ホスト鍵、パスワード、鍵のパスフレーズ、MFA、インストール、更新、再起動に承認が必要な場合、隠れたプロンプトを開くのではなく Attention と表示します。`herdr --remote workbox` など、Herdr が表示する単独接続用のコマンドを実行してフォアグラウンドでセットアップを完了し、クライアントを再起動してください。名前付きセッションを選んだプロファイルにだけ `--session <name>` を追加します。 + +ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。UI での選択は CLI の接続先を変えません。保存済み SSH マシンへ API コマンドを送るには、グローバルな**プレフィックス** `--machine <label-or-id>` を指定します。 + +```bash +herdr --machine "Build machine" agent list +herdr --machine <profile-id> pane list +herdr --machine "Build machine" agent prompt w1:p1 "review this change" +herdr --machine "Build machine" worktree create --cwd /srv/project --branch review +``` + +有効なプロファイル ID または一意のラベル(大文字・小文字を区別)を指定します。任意の SSH ホスト名は使えません。保存済みのリモートセッションを使うため、`--session` や `--remote` との併用はエラーです。省略時のローカル動作は変わりません。 + +対応するのは `workspace`、`worktree`、`tab`、`pane`、`notification`、`agent`(`attach` とローカルの `explain --file` 以外)、`api snapshot`、`status server`、API ベースのプラグイン操作(`link`、`unlink`、`enable`、`disable`、`list`、`action`、`log`、`pane`)です。サーバー操作は `stop`、`reload-config`、`agent-manifests`、`reload-agent-manifests` に対応します。ローカルのインストール・設定、プラグインのインストール、セッション管理、対話的な端末接続は転送しません。 + +JSON API を非対話 SSH で転送します。API の内容を SSH のシェルコマンドに埋め込むことはありません。開いた TUI は不要で、サーバーのインストール・起動・再起動も自動では行いません。ローカル CLI とリモート Herdr を更新し、実行中サーバーの API プロトコルとの互換性を保ってください。認証、接続、互換性のエラーで Local にフォールバックしたり、要求を自動再送したりすることはありません。 + +呼び出し元のローカルペイン ID は継承しません。リモート ID を明示してください。`--current` でローカルペインを指定することはできません。リモート worktree パスには絶対パス、`~`、または `~/` で始まるパスを指定します(サーバー側で展開)。プラグインの link パスは絶対パスが必要です。対象は 1 台ずつで、一覧の統合や別の TUI の接続を使った転送は含みません。 + +## シェル補完 + +```bash +herdr completion zsh +herdr completions zsh +herdr completion bash +herdr completion fish +herdr completion powershell +herdr completion elvish +``` + +`completion` はスクリプトを標準出力に表示します。`completions` はエイリアスです。一時的な zsh セッションでは、スクリプトを直接読み込めます: + +```bash +source <(herdr completion zsh) +``` + +永続的な zsh 設定では、生成された `_herdr` 関数を `compinit` が実行される前の `fpath` 上に置いてください: + +```bash +mkdir -p ~/.zfunc +herdr completion zsh > ~/.zfunc/_herdr +``` + +その後、`.zshrc` に次の内容があることを確認してください: + +```zsh +fpath=(~/.zfunc $fpath) +autoload -Uz compinit +compinit +``` + +## サーバー + +```bash +herdr server +herdr server stop +herdr server reload-config +herdr server agent-manifests [--json] +herdr server update-agent-manifests [--json] +herdr server reload-agent-manifests +``` + +`herdr server` はヘッドレスサーバーを明示的に起動します。監視下やサービス的な構成で使ってください。`reload-config` はペインを再起動せずにリロード可能な設定を適用します。`agent-manifests` は、アクティブなエージェント検出マニフェストのソース、キャッシュされたリモートバージョン、直近のリモート更新結果を表示します。`update-agent-manifests` はリモートマニフェストの更新を即座に取得し、実行中のサーバーにリロードして、更新後のマニフェスト状態を表示します。生のステータスレスポンスが欲しいときは `--json` を渡してください。`reload-agent-manifests` は、ローカルオーバーライドの編集後にエージェント検出マニフェストを実行中のサーバーにリロードします。 + +## 通知 + +```bash +herdr notification show <title> [--body TEXT] [--position top-left|top-right|bottom-left|bottom-right] [--sound none|done|request] +``` + +`notification show` は設定済みの `[ui.toast]` 配信を使います。`--position` はアプリ内の Herdr トーストにのみ影響します。`--sound` のデフォルトは `none` で、`done` と `request` は通知が表示されたときにのみ、既存の完了音と要注意音を再生します。 + +## セッション + +```bash +herdr session list [--json] +herdr session attach <name> +herdr session stop <name> [--json] +herdr session delete <name> [--json] +``` + +デフォルトセッションを明示的に停止する必要があるときは、セッション名として `default` を使ってください。 + +## ワークスペース + +```bash +herdr workspace list +herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus] +herdr workspace get <workspace_id> +herdr workspace focus <workspace_id> +herdr workspace rename <workspace_id> <label> +herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N] +herdr workspace close <workspace_id> [--group] +``` + +フォーカスを奪わずにワークスペースを作成します: + +```bash +herdr workspace create --cwd ~/project --label api --no-focus +``` + +ワークスペースは最上位のプロジェクトまたは作業コンテキストです。作成時に最初のタブとルートペインも作られます。JSON レスポンスの `.result.workspace.workspace_id`、`.result.tab.tab_id`、`.result.root_pane.pane_id` から各 ID を取得できます。 + +## Worktree + +```bash +herdr worktree list [--workspace ID | --cwd PATH] [--trust-repository] +herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--trust-repository] +herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository] +herdr worktree remove --workspace ID [--force] [--trust-repository] +``` + +worktree は、Git チェックアウトの出自情報を持つ通常の Herdr ワークスペースです。`worktree create` は Git worktree のチェックアウトを作成し、ワークスペースとして開き、親リポジトリのワークスペースとグループ化します。`--branch` が既存のローカルブランチを指す場合はそれをチェックアウトし、そうでなければ `--base` または `HEAD` からブランチを作成します。`--path` がない場合、チェックアウトは `<worktrees.directory>/<repo>/<branch-slug>` の下に作成されます。 + +`workspace close` は Herdr の状態だけを閉じます。リンクされた worktree ワークスペースが開いている間に親ワークスペースを閉じるには `--group` が必要です。指定しない場合、グループは開いたままになり `workspace_group_close_required` が返されます。`worktree remove` が明示的なチェックアウト削除の経路です。`git worktree remove` を実行し、ブランチは決して削除せず、Git がダーティなチェックアウトを拒否する場合は `--force` が必要です。 + +Git はデフォルトで、別のユーザーが所有するリポジトリを拒否します。リポジトリを別途確認済みの場合は `--trust-repository` を渡すと、解決されたパスをそのコマンドでのみ信頼します。Herdr は Git 設定を変更しません。 + +## タブ + +```bash +herdr tab list [--workspace <workspace_id>] +herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus] +herdr tab get <tab_id> +herdr tab focus <tab_id> +herdr tab rename <tab_id> <label> +herdr tab close <tab_id> +``` + +タブはワークスペース内の別のターミナルレイアウトです。`--workspace` を省略すると、`tab create` はアクティブなワークスペースを使い、存在しなければ失敗します。JSON レスポンスの `.result.tab.tab_id` と `.result.root_pane.pane_id` から ID を取得できます。 + +ワークスペースとタブの作成、およびペインの分割は、デフォルトではフォーカスを変えません。`--focus` は新しいレイアウトを選択し、`--no-focus` はデフォルトを明示します。`--cwd` がない場合、新しいターミナルは `terminal.new_cwd` の設定に従い、デフォルトでは元のペインまたはワークスペースを引き継ぎます。各 `--env KEY=VALUE` は新しいルートシェルの環境変数を追加または置換します。 + +## ペイン + +```bash +herdr pane list [--workspace <workspace_id>] +herdr pane current [--pane ID|--current] +herdr pane get <pane_id> +herdr pane layout [--pane ID|--current] +herdr pane process-info [--pane ID|--current] +herdr pane neighbor --direction left|right|up|down [--pane ID|--current] +herdr pane edges [--pane ID|--current] +herdr pane focus --direction left|right|up|down [--pane ID|--current] +herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current] +herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off] +herdr pane rename <pane_id> <label>|--clear +herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus] +herdr pane swap --direction left|right|up|down [--pane ID|--current] +herdr pane swap --source-pane ID --target-pane ID +herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus] +herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus] +herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus] +herdr pane close <pane_id> +``` + +`--current` を受け付けるペインコマンドは、Herdr ペイン内から実行した場合、呼び出し元の `HERDR_PANE_ID` を使います。`pane split` では、明示したペイン ID または `--pane ID` がそのペインを分割します。対象を省略すると `HERDR_PANE_ID` があれば呼び出し元ペインを、なければフォーカス中のペインを使います。`--current` は呼び出し元ペインを必須とし、`HERDR_PANE_ID` がない場合はエラーになります。分割レスポンスでは、新しいペイン ID は `.result.pane.pane_id` にあります。 + +`pane move` の後続コマンドには `.result.move_result.pane.pane_id` を使います。別ワークスペースへの移動ではワークスペース修飾付きのペイン ID が変わり、以前の値は `.result.move_result.previous_pane_id` に残ります。実行中プロセスの `HERDR_PANE_ID`、`HERDR_TAB_ID`、`HERDR_WORKSPACE_ID` は起動時の値のままですが、Herdr は以前のペイン ID をそのターミナルの別名として保持するため、`--current` を使うペインコマンドは引き続き解決できます。ライブエージェント名もターミナルに追従します。 + +出力を読む: + +```bash +herdr pane read <pane_id> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] [--raw] +herdr pane read <pane_id> --source visible --ansi +herdr pane read <pane_id> --source recent-unwrapped --lines 120 +``` + +`pane read` は UTF-8 のターミナルテキストを直接出力します。デフォルトでは ANSI エスケープを除去し、ソースに装飾が含まれる場合は `--format ansi` または `--ansi` で保持します。`detection` ソースは常にプレーンテキストです。recent 系ソースでは、`--lines N` は折り返し解除の前に末尾の N 描画行を選び、省略時は 80 行です。`visible` と `detection` では、省略時にスナップショット全体を返し、指定時は改行で区切られた末尾 N 行を返します。`agent read` も同じ出力と行数の動作です。 + +入力を送る: + +```bash +herdr pane send-text <pane_id> <text> +herdr pane send-keys <pane_id> <key> [key ...] +herdr pane run <pane_id> <command> +``` + +`<key>` は Herdr のキーコンボ構文を使います: `a` のような通常の印字可能キー、`enter`、`tab`、`esc`、`backspace`、`left`、`right`、`up`、`down` のような特殊キー、`ctrl+h`、`control+j`、`alt+x`、`shift+tab` のような修飾キーコード、`f1` のようなファンクションキー、そして `minus`、`plus`、`backtick` のような名前付き記号です。レガシーな `C-c` と `c-c` は `ctrl+c` のエイリアスとして受け付けられます。 + +標準表記は `esc` ですが、`escape` も使えます。 + +`pane run` は現在の bracketed paste モードを尊重し、テキストと Enter をアトミックに送信します。コマンドには `send-text` + `send-keys Enter` よりこちらを使ってください。個別の送信操作は低レベルのままで、コマンドを送信しません。 + +カスタムフックからエージェント状態を報告する: + +```bash +herdr pane report-agent <pane_id> \ + --source ID \ + --agent LABEL \ + --state idle|working|blocked|unknown \ + [--message TEXT] \ + [--seq N] \ + [--agent-session-id ID] \ + [--agent-session-path PATH] + +herdr pane report-agent-session <pane_id> \ + --source ID \ + --agent LABEL \ + [--seq N] \ + [--agent-session-id ID] \ + [--agent-session-path PATH] \ + [--session-start-source SOURCE] + +herdr pane release-agent <pane_id> \ + --source ID \ + --agent LABEL \ + [--seq N] +``` + +`report-agent-session` はライフサイクル状態を報告せずにネイティブセッション識別子を更新します。`release-agent` は、そのエージェントプロセスが終了したときに、そのソースのライフサイクル権限を終了します。 + +公式インテグレーションがネイティブセッション参照を報告している場合、`pane get`、`pane list`、`agent get`、`agent list` は読み取り専用の `agent_session` オブジェクトを含みます。ネイティブセッション参照が保存されていない場合、このフィールドは省略されます。 + +これらのコマンドは、ペインを制御しているフォアグラウンドプロセスの cwd を解決できる場合に `foreground_cwd` を含みます。既存の `cwd` フィールドは、ラベルと follow-cwd 挙動に使われるペイン/ワークスペースの cwd のままです。 + +意味的な状態を奪わずに、表示専用のペインメタデータを報告する: + +```bash +herdr pane report-metadata <pane_id> \ + --source ID \ + [--agent LABEL] \ + [--applies-to-source ID] \ + [--title TEXT|--clear-title] \ + [--display-agent TEXT|--clear-display-agent] \ + [--state-label STATUS=TEXT] \ + [--clear-state-labels] \ + [--token NAME=VALUE] \ + [--clear-token NAME] \ + [--seq N] \ + [--ttl-ms N] +``` + +`STATUS` は `idle`、`working`、`blocked`、`done`、`unknown` のいずれかです。`--agent` と `--applies-to-source` は `--title`、`--display-agent`、`--state-label` だけを保護し、トークンパッチは保護しません。トークンのクリアまたは TTL の更新は報告側が管理します。表示名を変えるには `--display-agent` を使ってください。 + +メタデータのテキストは保存前に正規化されます。Herdr は前後の空白を取り除き、制御文字を除去し、`--title`、`--display-agent`、各 `--state-label`、トークンの値を 80 文字に制限します。正規化後に空になったトークン値は、そのキーをクリアします。 + +`--token` は名前付き表示値を設定し、`--clear-token` は削除します。指定しなかったトークンは変わりません。ペイントークンはエージェントサイドバー行で `$name` として、ワークスペーストークンはスペース行で使えます。TTL は、その呼び出しで更新した各トークンキーに個別に適用されます。 + +`--source` と `--applies-to-source` は 80 文字以下で、ASCII の英字、数字、コロン、ドット、アンダースコア、ハイフンのみを含められます。`--ttl-ms` はメタデータを自動失効させ、`1` から `86400000` ミリ秒の間でなければなりません。置き換え・クリア・ペインまたはワークスペースのクローズまで残るべきメタデータでは省略してください。`--seq` により、Herdr は同じ `--source` からの古い報告を無視できます。古い報告は API には受理されますが、ペイン状態には無視されます。ペインまたはワークスペースが存続する間、シーケンス付きトークン報告を送れる異なるソースは最大 32 個で、クリアや失効でもその枠は解放されません。 + +## エージェント + +ペインとエージェントの使い分けや一連の連携例は、[エージェント自動化](/ja/docs/agent-automation/) を参照してください。 + +```bash +herdr agent list +herdr agent get <target> +herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] +herdr agent send-keys <target> <key> [key ...] +herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS] +herdr agent rename <target> <name>|--clear +herdr agent focus <target> +herdr agent wait <target> [--until STATUS]... [--timeout MS] +herdr agent attach <target> [--takeover] +herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>] +herdr agent explain <target> [--json|--verbose] +herdr agent explain --file PATH --agent LABEL [--json|--verbose] +``` + +エージェントターゲットは、一意なライブエージェント名、または現在そのエージェントをホストしているペイン ID です。ターミナル ID とエージェント kind のラベルだけでは指定できません。`agent start` で起動するエージェントには名前が必須で、手動で起動したエージェントは名前なしのままペイン ID で指定します。 + +`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki`、`muse` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 + +成功した start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 + +`agent prompt` は現在の bracketed paste モードを尊重し、working 中でもテキストと遅延した Enter を順序付きの 1 回の送信として書き込みます。`--wait` なしの成功は書き込みの完了を示し、ターンの開始を保証しません。Windows の Codex では Enter の前にペースト境界が送られるため、送信はプロンプトのサイズに依存しません。呼び出し側のタイムアウトには送信時間も含まれます。エージェントがすでに `blocked` の場合は、入力を送信せずに `agent_blocked` を返します。`--wait` を使う場合、別の non-working 状態から受け付けたプロンプトでは、送信後の最大 5 秒間に `working` または `blocked` が観測される必要があります。観測できなければ Herdr は `agent_prompt_stalled` を返し、呼び出し側のタイムアウトが先に切れた場合は通常の `timeout` エラーを返します。これにより、無関係な `idle`、`done`、またはセッションの変化によって待機が誤って完了することを防ぎます。活動を観測した後、要求された最初の安定状態を待ちます。個々のターンは追跡しません。すでに working の場合、進行中ターンの完了が待機を満たすことがあります。`--until` は一致状態を絞り込み、`--wait` なしでは拒否されます。単独の `agent wait` は現在の状態が一致すれば即座に返ります。どちらもデフォルトは `idle`、`done`、`blocked` です。 + +`idle` と `done` はどちらも入力可能な状態です。CLI/API はサーバーの既読状態を使い、未確認の idle を `done` とします。明示的な `pane focus` / `agent focus` は対象を既読にしますが、読み取りでは変わりません。各 TUI クライアントは表示済みの完了を独立して管理するため、Done バッジが CLI やほかのクライアントと異なる場合があります。`blocked` は承認または質問 UI を Herdr が認識した状態です。`unknown` はエージェントが存在するものの確実に分類できない状態で、作業の成功を意味しません。 + +`agent send-keys` は `enter`、`up`、`esc`、`ctrl+c` などの論理キーを送ります。Herdr は 1 バイトも書き込む前に全キーを検証します。`agent read` は解決されたターミナルストリームを読み、`agent rename` は検出済みエージェントに名前を付けます。 + +`agent explain` は、スクリーン検出が使うのと同じ下部バッファの検出スナップショットの分類を実行中のサーバーに依頼します。そのためライブの出力はサーバーのアクティブなマニフェストキャッシュを反映します。これは `agent.explain` ソケットメソッドを使うので、Herdr のアップグレード後にライブ explain を使う前に、サーバーを再起動するか更新済みサーバーへハンドオフしてください。保存済みフィクスチャをローカルで説明するには `--file PATH --agent LABEL` を使います。デフォルトの出力には、エージェント、最終状態、マニフェストのソースとバージョン、リージョンの証拠付きでマッチしたルール、そしてフォールバック・スキップ・警告の理由が表示されます。`--verbose` を付けると、可視の証拠フラグ、キャッシュされたリモートバージョン、ローカルオーバーライドのシャドーイング、リモート更新状況、マッチャーとリージョンの証拠付きの評価済みルール全リストが表示されます。issue の報告やテストには `--json` を付けてください。 + +通常のターミナル、サーバー、テスト、シェル、低レベルなターミナル制御には `pane send-text`、`pane send-keys`、`pane run`、`terminal attach` を使ってください。Enter 付きでコマンドを送信したいときは `pane run` を使います。 + +## ダイレクトターミナルアタッチ + +```bash +herdr terminal attach <terminal_id> [--takeover] +herdr terminal session control <target> [--takeover] [--cols N] [--rows N] +herdr terminal session observe <target> [--cols N] [--rows N] +herdr terminal title set <title> +herdr terminal title clear +``` + +ダイレクトアタッチからは `ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 +`terminal session control` は、ペイン、ターミナル、エージェントを対象に書き込み可能なライブストリームを開きます。observe と同じ改行区切り JSON の `terminal.frame` と `terminal.closed` を出力し、標準入力から `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` を受け取ります。制御クライアントは同時に 1 つだけで、`--takeover` で置き換えられます。 + +`terminal session observe` は同じ対象の読み取り専用ストリームを開きます。base64 の ANSI バイト列を含む `terminal.frame` と、サーバー終了時の `terminal.closed` を改行区切り JSON で出力します。複数の観測クライアントは入力、リサイズ、スクロール、制御権を取得せずに同じターミナルを監視できます。Linux/macOS ではソケットへの書き込みが 30 秒間進まない観測クライアントを切断します。その場合は最後の `terminal.closed` が届かないことがありますが、ペインや他のクライアントは動き続けます。出力のない静かなペインはこのタイムアウトの対象ではありません。 + +`terminal title clear` は外側ターミナルウィンドウタイトルを `ui.window_title` に戻します。 + +## 出力待機 + +ペインの出力を待つ: + +```bash +herdr pane wait-output <pane_id> (--match <text> | --regex <pattern>) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw] +``` + +通常のコマンドやサーバーには `pane wait-output` を使います。コーディングエージェントには `agent wait` を使います。 + +`pane wait-output` は選択したスナップショットを最初にすぐ検索するため、すでに存在する出力も一致します。デフォルトのソース名は `recent` で、直近 80 行の描画済みターミナル行を折り返し前の出力として扱います。`--lines` でその行数を変更できます。`--match` は 1 行内のリテラル部分文字列を検索し、`--regex` は Rust の正規表現構文で 1 行ずつ一致します。 + +タイムアウトや `agent_prompt_stalled` は未送信を保証しません。再試行前にエージェントを確認してください。 + +`pane wait-output` と `agent wait` は、`--timeout` を省略すると無期限に待ちます。`agent prompt --wait` では、活動を観測した後、またはプロンプト開始時に `working` だった場合の安定状態待機は無期限です。non-working 状態から開始して活動を観測できない場合は、5 秒後に `agent_prompt_stalled` を返します。タイムアウトまたはサーバーエラーは JSON として stderr に出力され、終了コード 1 になります。CLI 使用法のエラーは終了コード 2 です。 + +## インテグレーション + +```bash +herdr integration install pi +herdr integration install omp +herdr integration install claude +herdr integration install codex +herdr integration install copilot +herdr integration install devin +herdr integration install droid +herdr integration install kimi +herdr integration install opencode +herdr integration install kilo +herdr integration install hermes +herdr integration install mastracode +herdr integration install qodercli +herdr integration install qwen +herdr integration install letta +herdr integration install cursor +herdr integration uninstall pi +herdr integration uninstall omp +herdr integration uninstall claude +herdr integration uninstall codex +herdr integration uninstall copilot +herdr integration uninstall devin +herdr integration uninstall droid +herdr integration uninstall kimi +herdr integration uninstall opencode +herdr integration uninstall kilo +herdr integration uninstall hermes +herdr integration uninstall mastracode +herdr integration uninstall qodercli +herdr integration uninstall qwen +herdr integration uninstall letta +herdr integration uninstall cursor +herdr integration status [--outdated-only] +``` + +## プラグイン + +プラグインコマンドは、ローカル実行型ワークフロープラグインをインストールして実行します。プラグインはマニフェストとプロセス外コマンドの組み合わせです。ホスト側の面は Herdr が、実装言語はプラグインが担います。 + +プラグインのインストール、一覧、削除: + +```bash +herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes] +herdr plugin list [--plugin ID] [--json] +herdr plugin uninstall <plugin_id|owner/repo[/subdir...]> +herdr plugin enable <plugin_id> +herdr plugin disable <plugin_id> +``` + +`plugin install` は `ogulcancelik/herdr-plugin-examples/worktree-bootstrap` のような GitHub 省略記法のみを受け付けます。`git` を使い、対話的なターミナルでは信頼プレビューを表示し、サポートされるマニフェストのビルドコマンドを実行し、GitHub インストールを Herdr 管理のディレクトリに保存します。非対話的なインストールには `--yes` を使ってください。GitHub 管理プラグインの再インストールは、その管理チェックアウトを置き換えます。ローカルにリンクされたプラグインへの上書きインストールは拒否されます。プラグインのマニフェストは `min_herdr_version` を宣言しなければならず、プラグインがより新しい Herdr バイナリを要求する場合、install と link は失敗します。`plugin list` はデフォルトで人間可読です。生の API レスポンスが欲しいときは `--json` を渡してください。 + +ローカル開発: + +```bash +herdr plugin link <path> [--disabled] +herdr plugin unlink <plugin_id> +``` + +`plugin link` は `herdr-plugin.toml` を含むプラグインディレクトリ、またはマニフェストへの直接パスを受け付けます。ローカルチェックアウトからプラグインを作成・テストしている間はこれが正しいコマンドです。`plugin unlink` はプラグインの登録を解除し、ファイルには触れません。`plugin uninstall` はプラグインの登録を解除し、Herdr 管理の GitHub チェックアウトファイルも削除します。GitHub インストールの場合、uninstall はプラグイン id と、install で使うのと同じ `owner/repo[/subdir...]` 省略記法の両方を受け付けます。アクション、イベントフック、ペイン、リンクハンドラーはマニフェストで宣言します。ランタイムでのアクション登録は v1 の範囲外です。 + +設定ディレクトリ: + +```bash +herdr plugin config-dir <plugin_id> +``` + +`plugin config-dir` はプラグインの設定ディレクトリを表示し、必要なら作成します (レガシーなプラグイン設定の場所が存在すれば、そこから初期内容を移します)。セットアップドキュメントやシェルスクリプトで、管理されたプラグインチェックアウトとは別の、`.env` ファイルなどユーザーが編集する設定のための安定したパスをユーザーに示すのに使ってください。 + +アクション: + +```bash +herdr plugin action list [--plugin ID] +herdr plugin action invoke <action_id> [--plugin ID] +``` + +`plugin action invoke` は、インストール済みで有効かつプラットフォーム互換のプラグインアクションのマニフェストコマンドを起動し、開始されたコマンドのログレコードを JSON レスポンスに表示します。複数のプラグインが同じアクション id を使っている場合は、修飾されたアクション id (`plugin.id.action`) を使ってください。ローカルのアクション id はドットを含められないため、プラグイン id がドットを含んでいても修飾 id は曖昧になりません。 + +ログ: + +```bash +herdr plugin log list [--plugin ID] [--limit N] +``` + +管理されたターミナルペイン: + +```bash +herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|popup|split|tab|zoomed] [--width SIZE] [--height SIZE] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus] +herdr plugin pane focus <pane_id> +herdr plugin pane close <pane_id> +``` + +`plugin pane open` は、プラグインがリンクされ、有効で、現在のプラットフォームと互換であることを要求します。マニフェストで宣言された `[[panes]]` コマンドを Herdr 管理のターミナルペインとして起動します。マニフェストのデフォルトは `overlay` で、アクティブなペインの上に一時的なズームオーバーレイを開きます。分割、新しいタブ、ズームされたペイン、またはタブレイアウトを変更しないセッションモーダルな `popup` として開くこともできます。`--width` と `--height` は、外側のポップアップ寸法をターミナルセル数または `80%` のような割合で指定します。省略した寸法はデフォルトでターミナルの半分になり、小さすぎる値はポップアップの最小サイズに制限されます。ポップアップは Herdr ペインではなく、`HERDR_PANE_ID` を受け取らず、pane API やエージェント API に参加しません。ターミナル以外のネイティブなプラグインペインは今後の対応面です。 + +`--env KEY=VALUE` はプロセスを起動するコマンドで繰り返し指定できます。新しく起動されるプロセスにのみ適用されます。`HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ENV`、`HERDR_WORKSPACE_ID`、`HERDR_TAB_ID`、`HERDR_PANE_ID`、`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、`HERDR_PLUGIN_CONFIG_DIR`、`HERDR_PLUGIN_STATE_DIR`、`HERDR_PLUGIN_ENTRYPOINT_ID`、`HERDR_PLUGIN_CONTEXT_JSON` のような Herdr 管理の変数は、呼び出し側が与えた環境変数と衝突した場合も権威を保ちます。 + +## 読み取りソース + +| ソース | 意味 | +| --- | --- | +| `visible` | 現在レンダリングされている画面。UI のフィードバックループに最適。 | +| `recent` | ターミナルの折り返しを含む直近のスクロールバック。 | +| `recent-unwrapped` | ソフト折り返しなしの直近のスクロールバック。ログに最適。 | +| `detection` | エージェントのスクリーン検出が使う下部バッファのスナップショット。 | + +この表は読み取り時の意味です。`pane wait-output` に限り、`recent` と `recent-unwrapped` はどちらも recent の折り返し前スナップショットを検索し、デフォルト表記は `recent` です。 + +## 環境変数 + +| 変数 | 目的 | +| --- | --- | +| `HERDR_CONFIG_PATH` | 設定ファイルパスを上書きする。 | +| `HERDR_SESSION` | CLI コマンドの名前付きセッションを選択する。 | +| `HERDR_SOCKET_PATH` | 低レベルなソケットパスの上書き。 | +| `HERDR_ENV` | Herdr 管理のペインプロセス内で `1` に設定される。 | +| `HERDR_PANE_ID` | 実行中ペインプロセスの公開ペイン id。 | +| `HERDR_TAB_ID` | 実行中ペインプロセスの公開タブ id。 | +| `HERDR_WORKSPACE_ID` | 実行中ペインプロセスの公開ワークスペース id。 | +| `HERDR_LOG` | ログフィルターを設定する。例: `HERDR_LOG=herdr=debug`。 | +| `HERDR_DISABLE_SOUND` | サウンド通知が有効でも音の再生を無効にする。 | diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/concepts.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/concepts.mdx new file mode 100644 index 00000000..135454b6 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/concepts.mdx @@ -0,0 +1,89 @@ +--- +title: コンセプト +description: Herdr のワークスペース、タブ、ペイン、エージェント、セッション、モードを理解します。 +--- + +Herdr はターミナルワークスペースマネージャーです。実際のターミナルプロセスを動かし続け、その周りに構造を加えます。 + +## ワークスペース + +ワークスペースは最上位のプロジェクトコンテナです。リポジトリ、タスク、調査ごとにひとつのワークスペースを使ってください。 + +ワークスペースはタブとペインを所有します。サイドバーの状態は内部のエージェントから集約されるので、どのプロジェクトが注意を必要としているかが分かります。 + +## タブ + +タブはワークスペース内のレイアウトです。`agents`、`logs`、`server`、`review` のようにビューを分けるために使います。 + +タブは CLI とソケット API からアドレス指定できます。 + +## ペイン + +ペインは実際のターミナルです。Herdr はターミナル出力を描画し、入力をプロセスに送り返し、クライアントのデタッチ後もペインを維持します。 + +ペインは右または下に分割できます。手動での名前変更、CLI からの読み取り、入力の送信、クローズができます。 + +## マウス UI + +Herdr はマウスネイティブです。ペイン、タブ、ワークスペース、エージェントをクリックできます。分割境界のドラッグ、テキスト選択、右クリックメニューも使えます。以下で説明する操作はすべてマウスでも行えます。キーボードバインドは任意のレイヤーです。 + +キーボードのみで操作したい場合や、Herdr にマウス入力をキャプチャさせたくない場合は、マウスキャプチャを無効にしてください: + +```toml +[ui] +mouse_capture = false +``` + +## エージェント + +エージェントは、Herdr がペイン内で認識するプロセスです。Herdr はフォアグラウンドプロセス、スクリーンマニフェスト、任意のインテグレーションからエージェントを検出します。 + +エージェントの状態は次のとおりです: + +| 状態 | 意味 | +| --- | --- | +| `blocked` | エージェントが入力、承認、または判断を必要としている。 | +| `working` | エージェントが実行中。 | +| `done` | エージェントが完了し、まだ確認されていない。 | +| `idle` | エージェントが完了または待機していて、確認済み。 | +| `unknown` | Herdr が状態を確信を持って分類できない。 | + +各クライアントは表示した完了を独立して管理します。あるクライアントで完了を見ても、ほかのクライアントの Done バッジは消えません。CLI/API はサーバーの既読状態を使うため、特定のクライアントのバッジと一致するとは限りません。`idle` と `done` はどちらも入力可能な状態です。 + +## セッション + +セッションは永続的な Herdr サーバーの名前空間です。デフォルトの `herdr` コマンドはデフォルトセッションにアタッチします。 + +名前付きセッションは独立したランタイム名前空間です: + +```bash +herdr session list +herdr session attach work +herdr session attach side-project +``` + +まずはワークスペースを使ってください。ペイン、ソケット、永続化されたランタイム状態を完全に分離する必要があるときに名前付きセッションを使います。 + +## クライアントとサーバー + +デフォルトでは、Herdr はバックグラウンドサーバーと、それにアタッチした 1 つ以上のクライアントとして動作します。 + +サーバーはペインとプロセス状態を所有します。クライアントはそのサーバーにアタッチしたターミナル UI です。 + +クライアントが 1 つだけ接続されている場合、従来どおりすべてのタブがそのサイズに従います。複数のクライアントが接続されている場合、それぞれ別のワークスペースやタブを表示できます。異なるタブはそれぞれを表示するクライアントのサイズに従い、同じタブを表示している場合は、最後にそのタブへフォーカス、選択、または操作したクライアントがペインのサイズを制御します。 + +`ctrl+b q` でクライアントをデタッチします。サーバーとエージェントは動き続けます。 + +セッションを終了してそのペインを停止したい場合は、サーバーを停止します: + +```bash +herdr server stop +``` + +## モード + +Herdr にはターミナルモード、プレフィックスモード、ナビゲートモードがあります。 + +ターミナルモードはフォーカス中のペインにキーを送ります。プレフィックスモードはプレフィックスキーの後に Herdr のアクションをひとつ待ちます。ナビゲートモードは常駐のワークスペースナビゲーション画面です。 + +プレフィックスキー (デフォルト `ctrl+b`) を押してから、新しいタブなら `c`、ワークスペースナビゲーションなら `w` のようにアクションキーを押します。プレフィックスの考え方が初めてなら[キーボード](/ja/docs/keyboard/)を参照してください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/config-reference.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/config-reference.mdx new file mode 100644 index 00000000..7bbb60aa --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/config-reference.mdx @@ -0,0 +1,27 @@ +--- +title: 設定リファレンス +description: Herdr の正規の config.toml キーを、型・デフォルト値・許容値つきで一覧します。 +--- + +import ConfigReference from '../../../components/ConfigReference.astro'; + +Herdr が `config.toml` から読み取る正規キーを、フラットに一覧して絞り込めます。 +ガイド付きの設定手順や各オプションの背景は[設定](/ja/docs/configuration/)を参照してください。 + +コメント付きのデフォルト設定全体はいつでも出力できます: + +```bash +herdr --default-config +``` + +カスタムコマンドバインディング(`[[keys.command]]`)はユーザー定義のテーブルのため、 +ここではキーごとに掲載していません。 +[カスタムコマンドキーバインド](/ja/docs/configuration/#カスタムコマンドキーバインド)を参照してください。 + +各キーの説明は現時点では英語です。 + +<ConfigReference + placeholder="キーを絞り込み(例: sidebar, prefix, sound…)" + noMatches="一致するキーはありません。" + keyCountLabel="キー" +/> diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/configuration.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/configuration.mdx new file mode 100644 index 00000000..12802e84 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/configuration.mdx @@ -0,0 +1,584 @@ +--- +title: 設定 +description: Herdr のキーバインド、テーマ、サイドバーの挙動、通知、高度なオプションを設定します。 +--- + +Herdr は設定ファイルなしで動作します。キー、テーマ、サイドバーのレイアウト、通知、高度な挙動をカスタマイズしたい場合に追加してください。 + +設定やキーバインドを探していますか?すべてのキー、型、デフォルト値、許容値は[設定リファレンス](/docs/config-reference/)で検索できます。このページでは、セットアップ、一般的なレシピ、リファレンスの一行だけでは説明しきれない設定構造を中心に説明します。 + +## 設定ファイル + +Herdr は次の場所から設定を読み込みます: + +```text +Linux and macOS: ~/.config/herdr/config.toml +Windows: %APPDATA%\herdr\config.toml +``` + +システム上で解決された設定ファイルのパスは、`herdr --help` で確認できます。 + +完全なデフォルト設定を表示します: + +```bash +herdr --default-config +``` + +完全な出発点が欲しい場合は、これを設定として保存してください: + +```bash +herdr --default-config > ~/.config/herdr/config.toml +``` + +設定値が無効な場合、Herdr は安全なデフォルト値にフォールバックし、起動時に警告を表示します。 + +`onboarding` が存在しないか true の場合、Herdr は初回セットアップを表示します。オンボーディングから先へ進むと `onboarding = false` が書き込まれ、設定のインテグレーションタブが開きます。セットアップ後にこのフローをスキップしたい場合に設定してください。 + +```toml +onboarding = false +``` + +## 設定のリロード + +`config.toml` を編集した後、実行中のサーバーをリロードします: + +```bash +herdr server reload-config +``` + +Herdr のグローバルメニューを開いて `reload config` を選ぶこともできます。 + +リロードは、ペインを再起動せずにほとんどの UI 設定を適用します。起動時のみの設定には引き続き再起動が必要です。 + +テーマ、サイドバーのレイアウト、コピー動作などの表示設定は、SSH マシンを表示している場合もクライアントのローカル設定から読み込みます。ペインのデフォルト、worktree、インテグレーション、カスタムコマンドは、ペインが動くサーバー側の設定です。UI の `reload config` は、クライアントのローカル設定と選択中サーバーの設定を両方リロードします。ローカルキーバインドも反映されます。`--remote-keybindings server` を指定した場合は、選択中サーバーのキーバインドを使います。 + +## ヘッドレスターミナルのサイズ + +クライアントが接続されていない場合、サーバーはレイアウトと新しく作成されるペインに 120×40 の仮想ターミナルを使います。ヘッドレスオーケストレーション用のフォールバックは次のように変更できます: + +```toml +[server] +headless_cols = 160 +headless_rows = 50 +``` + +クライアントが 1 つだけ接続されている場合、すべてのタブがそのサイズに従います。複数のクライアントが接続されている場合、表示中の各タブは、最後にそのタブへフォーカス、選択、または操作したクライアントのサイズに従います。1 つを除くすべてのクライアントが切断すると、すべてのタブは残ったクライアントのサイズへすぐに戻ります。クライアントが 1 つも残っていない場合、既存のペイン PTY は最後のサイズを保ち、新しいヘッドレスレイアウトには設定したフォールバックが使われます。 + +## ターミナルのデフォルト + +新しく作成される対話型ペインに Herdr が使う実行ファイルを設定します: + +```toml +[terminal] +default_shell = "nu" +``` + +未設定または空の場合、Herdr は `$SHELL`、次に Unix では `/bin/sh`、Windows では PowerShell を使います。これは実行ファイル名またはパスであり、シェルのコマンドラインではありません。既存のペインは再作成されるまで現在のシェルを保ちます。カスタムコマンドキーバインドの文字列は、Unix ではペインコマンドに `/bin/sh -c`、デタッチコマンドに `/bin/sh -lc` を通して実行され、Windows では `cmd.exe /d /c` を通して実行されます。 + +新しく作成される対話型ペインのシェルの起動方法を設定します: + +```toml +[terminal] +shell_mode = "auto" +``` + +`shell_mode = "auto"` は macOS でログインシェルを起動するため、`/usr/libexec/path_helper` のようなログイン時のみの PATH 設定や Homebrew のシェル初期化が新しいペインで実行されます。他のプラットフォームでは、既存の非ログインシェルの挙動を保ちます。ログインシェルでの起動を強制するには `"login"` を、無効にするには `"non_login"` を使ってください。コマンドペイン、デタッチされたカスタムコマンドキーバインド、明示的な argv 起動は、既存のコマンド実行経路を保ちます。 + +新しいペイン、タブ、ワークスペースの作業ディレクトリポリシーを設定します: + +```toml +[terminal] +new_cwd = "follow" +``` + +`new_cwd = "follow"` はデフォルトの挙動を保ち、元のペインまたはワークスペースを継承します。元のワークスペースがない場合、Herdr は `$HOME` で起動します。常に `$HOME` で起動するには `"home"` を、Herdr のプロセスディレクトリを使うには `"current"` を、または `"~/Projects"` のような固定パスを使ってください。CLI またはソケット API から明示的に指定された `--cwd` の値は引き続き優先されます。 + +## Worktree + +サイドバーから作成される Git worktree チェックアウトに Herdr が使うルートディレクトリを設定します: + +```toml +[worktrees] +directory = "~/.herdr/worktrees" +``` + +Herdr は `<directory>/<repo>/<branch-slug>` の下にチェックアウトを作成します。隣接ディレクトリ方式のチェックアウトには、`~/Projects/herdr-worktrees` のようなディレクトリを設定してください。相対値は、アプリが設定を適用するときに絶対パスへ解決されます。 + +worktree アクションは Git ワークスペースの行から使えます。`New worktree` はチェックアウトを作成し、入力したブランチがすでに存在する場合は既存のローカルブランチをチェックアウトし、存在しない場合はブランチを作成します。その後、新しい Herdr ワークスペースとして開き、元のワークスペースの下にグループ化します。`Open worktree...` はそのリポジトリにある既存の Git worktree チェックアウトを一覧表示します。すでに開いているチェックアウトを選ぶとそこへフォーカスし、閉じているチェックアウトを選ぶと同じグループで開きます。 + +グループ化された worktree も通常の Herdr ワークスペースと同様に動作します。フォーカス、名前変更、クローズができ、それぞれ独自のタブとペインを持ちます。親の行は元のワークスペースです。親の行を閉じると Herdr のグループ全体が閉じますが、チェックアウトのフォルダーやブランチは削除されません。 + +worktree チェックアウトの削除は明示的に行います。グループ化された子ワークスペースで `Delete worktree checkout...` を使うと `git worktree remove` が実行されます。Herdr はまず Git に安全な削除を要求します。チェックアウトに変更済みまたは未追跡のファイルがあり Git が拒否した場合、Herdr は強制削除を実行する前にもう一度確認します。ブランチは削除されません。 + +## リモートアタッチ + +リモートアタッチは、デフォルトで一時的なキープアライブと、対応する環境では接続再利用を使って SSH 接続を管理します。 + +```toml +[remote] +manage_ssh_config = true +``` + +有効な場合、`herdr --remote` は最初にユーザーとシステムの SSH 設定を取り込み、その後にフォールバックの `ServerAliveInterval` と `ServerAliveCountMax` の値を加えた、プライベートな一時 SSH 設定を書き込みます。ユーザー自身のキープアライブ設定が優先されます。Linux と macOS クライアントは、最初に認証した接続を再利用するため、アタッチごとにプライベートな OpenSSH コントロールソケットも使います。Windows OpenSSH ではこの接続再利用を使いません。Herdr が生成した設定やコントロールソケットを使わず、通常の `ssh` でリモートアタッチを実行するには `manage_ssh_config = false` を設定してください。 + +## キーバインド + +プレフィックスの導入ガイドと検証済みのプレフィックスなし構成については、[キーボード](/docs/keyboard/)を参照してください。 + +Herdr には tmux に似たプレフィックスモードがあります。デフォルトのプレフィックスは `ctrl+b` です。キーバインド文字列は明示的です。`prefix+n` は設定されたプレフィックスを押してから `n` を押すという意味で、`ctrl+alt+n` はターミナルモードの直接ショートカットです。 + +小さなキーバインドの上書きは次のようになります: + +```toml +[keys] +prefix = "ctrl+b" +goto = "prefix+g" +new_tab = "prefix+c" +next_tab = "prefix+n" +previous_tab = "prefix+p" +focus_pane_left = "prefix+h" +navigate_workspace_down = "j" +navigate_pane_down = "ctrl+j" +split_horizontal = "prefix+minus" +``` + +デフォルトのキーマップはプレフィックス優先なので、Herdr がシェル、エディタ、tmux、ターミナルアプリから入力を奪うことはありません。[設定リファレンス](/docs/config-reference/)で `keys.` を検索すると、すべてのアクションとデフォルトのバインドを確認できます。`prefix+?` で開くアプリ内ヘルプパネルには、現在有効なバインドが表示されます。 + +1 つのアクションに複数のショートカットが必要な場合、バインドを配列にすることもできます: + +```toml +[keys] +next_tab = ["prefix+n", "ctrl+alt+]"] +``` + +任意のアクションはデフォルトでは未設定です。プレフィックスモードの挙動には `prefix+` でバインドし、意図的に直接ショートカットにする場合は明示的な修飾キーコードを使ってください。たとえば、リサイズモードに入らず tmux のように 1 回のキー操作でペインをリサイズできます: + +```toml +[keys] +resize_pane_left = "ctrl+shift+alt+left" +resize_pane_down = "ctrl+shift+alt+down" +resize_pane_up = "ctrl+shift+alt+up" +resize_pane_right = "ctrl+shift+alt+right" +``` + +キー文字列には、通常のキー、`ctrl+a`、`shift+n`、`alt+1`、`cmd+k` のような修飾キーの組み合わせ、`enter`、`tab`、`esc`、`left`、`right`、`up`、`down` のような特殊キーが使えます。`minus`、`comma`、`ampersand`、`plus`、`backtick` のような名前付き記号も使えます。`n` のような通常の印字可能キーの直接バインドは入力を妨げるため危険です。意図的に直接バインドするのでなければ `prefix+n` を使ってください。`navigate_workspace_*` と `navigate_pane_*` のフィールドはナビゲートモード専用で、`j` や `k` のような通常のキーを使えます。これらには `prefix+`、`esc`、`enter`、`tab`、`shift+tab`、`left`、`right`、修飾なしの `1` から `9` は使えません。左右の矢印キーは、左ペインと右ペインへのナビゲーションの恒久的なエイリアスです。これらのナビゲートモードショートカットは、`focus_pane_down = "prefix+j"` のような一般アクションのバインドから独立しています。両方に同じキーが使われている場合、ナビゲートモードが開いている間はナビゲートモードのショートカットが優先されます。Alt、Cmd/Super、修飾キー付き記号は、ターミナルと tmux の設定に依存します。 + +古いカスタムキーバインドを使っていて新しいデフォルトが欲しい場合は、`herdr config reset-keys` を実行してください。Herdr は `config.toml` をバックアップし、`[keys]` と `[[keys.command]]` を削除し、再起動または `herdr server reload-config` の後に組み込みの v2 デフォルトを使います。 + +## インデックス付きジャンプ + +インデックス付きキーバインドは、通常のキーバインドフィールドで `1..9` を使います: + +```toml +[keys] +switch_tab = "prefix+1..9" +switch_workspace = "prefix+shift+1..9" +focus_agent = "prefix+alt+1..9" +``` + +レガシーな `[keys.indexed]` テーブルは互換性のために引き続きパースされますが、新しい設定では明示的なアクションフィールドを使ってください。 + +## カスタムコマンドキーバインド + +カスタムコマンドも同じキーバインド構文を使います。 + +```toml +[[keys.command]] +key = "prefix+alt+g" +type = "popup" +command = "lazygit" +description = "run lazygit" +width = "80%" +height = "80%" +``` + +`type = "popup"` は、タブレイアウトを変更せずにセッションモーダルなポップアップを開きます。 +ポップアップは、コマンドが終了するまで Escape を含むすべてのターミナル入力を受け取ります。 +`width` と `height` は任意です。省略するとデフォルトの半分のサイズになり、数値ならターミナルセル数、`"80%"` のような文字列ならターミナル領域に対する割合を指定できます。 +寸法にはポップアップの枠が含まれ、小さすぎる値は最小サイズに制限されます。 +ポップアップコマンドは `HERDR_PANE_ID` を受け取りません。背後のタイルペインには `HERDR_ACTIVE_PANE_ID` を使ってください。 + +Unix と macOS では、分割やタブを追加せずに一時的なターミナルを開くこともできます: + +```toml +[[keys.command]] +key = "prefix+t" +type = "popup" +command = "exec \"${SHELL:-sh}\"" +description = "open scratch terminal" +width = "80%" +height = "80%" +``` + +Windows では、代わりに `command = "powershell.exe -NoLogo"` のようなシェルコマンドを使ってください。 +シェルを終了するとポップアップが閉じ、タイル表示に戻ります。 + +`type = "pane"` は一時的なペインを開き、コマンドの終了時に閉じます。 + +`type = "shell"` はバックグラウンドでデタッチ実行します。 + +`type = "plugin_action"` はインストール済みプラグインのアクション id を呼び出します。アクション id がグローバルに一意でない場合は修飾 id を使ってください: + +```toml +[[keys.command]] +key = "prefix+l" +type = "plugin_action" +command = "example.layout.apply" +description = "apply layout" +``` + +任意で `description` を指定できます。指定すると、キーバインドヘルプパネル(`prefix+?` で開く)に、デフォルトの `'custom command'` ラベルの代わりに表示されます。 + +カスタムコマンドは、利用可能な場合に `HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ACTIVE_WORKSPACE_ID`、`HERDR_ACTIVE_TAB_ID`、`HERDR_ACTIVE_PANE_ID`、`HERDR_ACTIVE_PANE_CWD` を受け取ります。Herdr が検出できる場合、シェルコマンドはフォーカス中のペインの作業ディレクトリから実行されます。 + +Windows では、カスタムコマンド文字列に `cmd.exe /d /c` が使われるため、環境変数には `%HERDR_BIN_PATH%` 構文を使います。PowerShell 構文を実行するには、たとえば `powershell.exe -NoProfile -Command "..."` のように明示的に呼び出してください。 + +## テーマ + +組み込みテーマを選びます: + +```toml +[theme] +name = "catppuccin" +``` + +すべての組み込みテーマは、[設定リファレンス](/docs/config-reference/)で `theme.name` を検索してください。Herdr の UI 色をホストターミナルの ANSI パレットに従わせたいときは `terminal` を使ってください。 + +ホストターミナルがライト/ダークの外観変更を報告したときに、Herdr が自身の UI テーマを切り替えるようにするには、テーマの自動切り替えを有効にします: + +```toml +[theme] +name = "catppuccin" +auto_switch = true +light_name = "catppuccin-latte" +dark_name = "catppuccin" +``` + +`auto_switch` のデフォルトは `false` なので、既存のテーマ設定は手動の挙動を保ちます。`light_name` または `dark_name` を省略すると、設定された `name` に対応する組み込みの姉妹テーマが存在する場合はそれを使います。たとえば `tokyo-night`/`tokyo-night-day` や `gruvbox`/`gruvbox-light` です。設定画面でテーマを手動選択すると `auto_switch` は無効になります。 + +個々の色を上書きできます: + +```toml +[theme.custom] +sidebar_bg = "#181825" +active_row_bg = "#1e1e2e" +selection_bg = "#313244" +panel_bg = "reset" +accent = "#a6e3a1" +green = "#a6e3a1" +blue = "#89b4fa" +red = "#f38ba8" +yellow = "#f9e2af" +``` + +`sidebar_bg` を使うと、デスクトップのサイドバーだけに背景色を設定できます。省略した場合、サイドバーはホストターミナルの背景を使います。`active_row_bg` は、区切り線やスクロールバートラックに影響を与えず、アクティブな Space とフォーカス中の Agent 行の背景色を変更します。`selection_bg` は、サイドバーの Navigate モードのカーソル行の背景色を変更します。 + +色の値には、hex、名前付きの色、`rgb(r,g,b)`、または `reset`、`default`、`none`、`transparent` のようなリセットエイリアスが使えます。 + +`auto_switch` が有効な場合は、ライト/ダーク別のサブテーブルを共有カスタム色の上に重ねられます: + +```toml +[theme.custom] +accent = "#89b4fa" + +[theme.custom.light] +panel_bg = "#eff1f5" +text = "#4c4f69" + +[theme.custom.dark] +panel_bg = "#1e1e2e" +text = "#cdd6f4" +``` + +有効なパレットには、組み込みテーマ、`[theme.custom]`、`[theme.custom.light]` または `[theme.custom.dark]` の順で適用されます。モード別のサブテーブルを省略すると、既存の共有上書きの挙動が維持されます。 + +## UI とサイドバー + +サイドバーは Herdr のメインダッシュボードです。サイズ、折りたたみモード、Agent パネルの並び順、マウスの挙動、ペインの境界線、その他の表示設定については、[設定リファレンス](/docs/config-reference/)で `ui.` を検索してください。 + +`ui.pane_borders` は `"auto"`(デフォルト。分割ペインだけに境界線を表示)、`"always"`(単独ペインにも枠を表示)、`"off"` を受け付けます。単独ペインの辺はすべて外側の辺なので、枠を表示するには `ui.pane_outer_borders = true` も必要です。従来の真偽値も有効で、`true` は `"auto"`、`false` は `"off"` に対応します。 + +`[ui]` の `tab_bar_position = "bottom"` を設定すると、デスクトップのタブ行をターミナルペインの下に配置できます。Prefix、Navigate、Copy、Resize の各モードバーは、表示中だけ下部のタブ行を置き換えます。デフォルトは `"top"` です。 + +タブ行の右端に、tmux のような順序付きステータス領域を設定できます: + +```toml +[ui] +tab_bar_right = [ + { type = "zoom" }, + { type = "hostname" }, + { type = "datetime", format = "%H:%M" }, + { type = "text", text = "prod" }, + { type = "command", command = "~/.config/herdr/status.sh", interval_seconds = 5, timeout_seconds = 2 }, +] +tab_bar_right_separator = " · " +``` + +ステータス領域はデフォルトでは空です。`zoom` を追加すると、アクティブなタブがズーム中のとき固定の `ZOOM` ピルが表示されます。各タブの既存の `Z` マーカーとは独立しています。`hostname`、`datetime`、`command` は Herdr サーバー上で解決されるため、`herdr --remote` ではリモートマシンの値が表示されます。日時エントリは `strftime` 形式を使います。`%z` や `%s` のように UTC オフセットまたは Unix タイムスタンプを必要とする指定子は、値がサーバーローカルの壁時計時刻であるため拒否されます。 + +コマンドエントリはすぐに実行され、その後 `interval_seconds` ごとに更新されます。描画をブロックせず、前回の実行と重複しません。間隔は 1〜31,536,000 秒、タイムアウトは 1〜3,600 秒です。Herdr は成功した出力の最終行を使い、ESC で始まるターミナル制御シーケンスは装飾として解釈せずに除去します。失敗、空の出力、`timeout_seconds` 経過後には値を消去します。カスタムコマンドキーバインドと同じアクティブなワークスペース、タブ、ペイン、ソケット、バイナリ、作業ディレクトリのコンテキストが提供されます。コマンドは Linux、macOS、Windows で利用でき、Linux と macOS では `/bin/sh -lc`、Windows では `cmd.exe /d /c` が使われます。 + +区切り文字は、表示されているエントリの間にだけ挿入されます。直接連結するには `tab_bar_right_separator = ""` を設定します。タブ行が狭い場合、ステータス領域全体がタブとそのコントロールに場所を譲ります。 + +### 外側のターミナルのウィンドウタイトル + +Herdr はペイン内のターミナルをエミュレートするため、ペイン内で書き込まれた `OSC 0`/`OSC 2` のタイトルは Herdr で止まります。Herdr は自身が動作しているターミナルに独自のタイトルを書き込み、ウィンドウマネージャーやターミナルのタブバーはこれを読み取ります: + +```toml +[ui] +window_title = "{hostname}: {workspace}" +``` + +トークンは `{hostname}`、`{workspace}`、`{tab}`、`{pane}`(フォーカス中のペインの手動名)、`{terminal_title}`(フォーカス中のペイン自身のターミナルタイトルからスピナーを取り除いたもの)です。波括弧そのものを書くには `{{` と `}}` を使います。値のないトークンは空になります。 + +タイトルは Herdr サーバー上で生成されるため、`herdr --remote` で接続した場合や SSH 越しに `herdr` を実行した場合でも、`{hostname}` はペインが動作しているマシンを指します。外側のターミナルのタイトルに触れないようにするには `window_title = ""` を設定します。 + +`client.window_title.set` は設定されたタイトルを上書きし、`client.window_title.clear` がそれを戻します。 + +Agent の状態は、デフォルトではコンパクトな色付きドットで表示されます。blocked、working、done、idle、unknown を色だけでなく形でも区別するには、設定画面で **distinct symbols** を選ぶか、次のように設定します: + +```toml +[ui] +status_indicators = "symbols" +``` + +これらの記号は静的なので、このオプションでスピナーアニメーションが有効になることはありません。 + +### サイドバーの行レイアウト + +展開されたデスクトップサイドバーでは、`rows` の各内側の配列が 1 行として描画されます。完全なデフォルトレイアウトは次のとおりです: + +```toml +[ui.sidebar.agents] +row_gap = 0 +rows = [ + ["state_icon", "machine", "workspace", "tab"], + ["agent"], +] + +[ui.sidebar.spaces] +row_gap = 0 +rows = [ + ["state_icon", "workspace"], + ["branch", "git_status"], +] +``` + +Agent の行では、次の組み込みトークンを使えます: + +- `state_icon` — エージェントの意味的な状態を示す色付きアイコン。 +- `state_text` — `idle`、`working`、`blocked`、`done`、`unknown`。報告された表示ラベルがある場合はそれも含みます。 +- `machine` — クライアントに複数のマシンがある場合のマシンラベル。ローカルマシン 1 台だけの場合は省略されます。 +- `workspace` — ワークスペース名。 +- `tab` — 利用可能な場合はタブ名。 +- `pane` — 利用可能な場合はペイン名。 +- `agent` — 検出または報告されたエージェントの表示名。 +- `terminal_title` — 安全性のための正規化後の最新の OSC 0/2 ターミナルタイトル。 +- `terminal_title_stripped` — 認識された先頭のアクティビティまたはスピナーのグリフ 1 つと、それに続く空白を除去したターミナルタイトル。 +- `$name` — `name` という名前のカスタムペインメタデータ。 + +Space の行では、次の組み込みトークンを使えます: + +- `state_icon` — Space 内で集約されたエージェント状態を示す色付きアイコン。 +- `state_text` — Space 内で集約されたエージェント状態のテキスト。 +- `workspace` — ワークスペース名。 +- `branch` — 利用可能な場合は Git ブランチ。 +- `git_status` — 0 でない場合は Git の ahead と behind の件数。 +- `$name` — `name` という名前のカスタムワークスペースメタデータ。 + +トークンは設定された順序で描画されます。Herdr は通常、隣接する値を ` · ` で区切り、`state_icon` の後には空白を 1 つ入れます。値がない場合、その値と区切りは表示されません。すべてのトークンに値がない行は表示されません。既存のカスタム行は変更されません。カスタムの複数マシンレイアウトでマシンを識別したい場合は、`machine` を明示的に追加してください。各レイアウトは最大 16 行、各行は最大 16 トークンです。 + +トークンエントリにはインラインのスタイルテーブルも使えます: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", { token = "workspace", bold = false }, "tab"], + [{ token = "$summary", fg = "#89b4fa", bold = true, dim = false }], +] +``` + +`fg` は厳密な `#RGB` または `#RRGGBB`、`bold` と `dim` は真偽値です。省略したフィールドはコンテキストのスタイルを保持し、明示的な `false` はその修飾を解除します。スタイルは 1 つの出現箇所だけに適用されます。`fg` を指定した `git_status` では、既定の緑/赤ではなく ahead と behind の両方が同じ色になります。区切りと行の背景は変更されません。 + +テキスト値のトークンには、順序付きの `rules` を最大 16 個指定できます。各ルールには `equals`、`contains`、`starts_with`、`gt`、`lt` のいずれか 1 つの条件と、任意の `fg`、`bold`、`dim` 上書きを指定します: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "workspace", "tab"], + [{ token = "machine", fg = "#fff", rules = [{ equals = "Local", fg = "#f55" }, { equals = "Fedora", ignore_case = true, fg = "#51a2da" }] }, "agent"], + [{ token = "$load", fg = "#fff", rules = [{ gt = 80, fg = "#f55", bold = true }, { gt = 50, fg = "#fc0" }] }], +] +``` + +最初に一致したルールだけを適用します。指定されたスタイルはその出現箇所のデフォルトを上書きし、省略したフィールドは継承します。一致しなければデフォルトを保ちます。判定には表示幅で切り詰める前の値全体を使います。ルールに `hide = true` を指定すると、一致したトークンとその区切りを非表示にします。残るトークンがない行も非表示になります。たとえば `{ token = "machine", fg = "#61afef", rules = [{ equals = "Local", hide = true }] }` は、ラベルが正確に `Local` の場合にマシントークンを非表示にします。接続の種類ではなくラベルを比較します。`hide = false` または省略時は表示を維持します。上書きを指定しないルールでも、一致するとそこで判定を終了してデフォルトを保ちます。 + +`equals`、`contains`、`starts_with` は文字列を受け取り、大文字と小文字を区別します。`ignore_case = true` は ASCII だけを区別しなくなり、非 ASCII 文字は引き続き区別します。空文字列の `equals` は空の値だけに一致し、空の `contains` と `starts_with` は存在するすべての値に一致します。 + +`gt` と `lt` は有限の数値を閾値とし、等しい値は含みません。値全体が有限の数値として解析できる必要があり、小数と指数表記は使えますが、空白、単位、`NaN`、無限大は一致しません。数値ルールに `ignore_case` は指定できません。比較は浮動小数点で行い、大きな整数の厳密な比較には使えません。 + +ルールは Agent 行、`rows_by_agent` の上書き、Space 行のテキスト値の組み込みトークンとカスタム `$name` トークンに使えます。上の例では `$load` が `"90"` なら赤の太字、`"60"` なら黄色、`"90%"` なら白のままです。未報告のトークンは表示されません。`state_icon` と複合トークン `git_status` は固定スタイルだけを受け付け、ルールは使えません。不明な条件や不正なルールは設定読み込み時に拒否します。正規表現、あいまい一致、スクリプトには対応しません。 + +`row_gap` は、Agent パネルと Space パネルごとに、エントリ間の空白行数を指定します。デフォルトは `0` で、エントリを詰めて表示します。以前の間隔に戻すには `1` に設定します。`rows` で定義したコンテンツ行の間隔には影響しません。連続するインデントされた worktree の子は、1 つの Space グループとして詰めて表示されます。 + +既知のエージェントについて Agent の完全なレイアウトを上書きするには、`rows_by_agent` を使います: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "agent", "state_text"], + ["workspace", "tab"], +] + +[ui.sidebar.agents.rows_by_agent] +claude = [ + ["state_icon", "agent", "state_text"], + ["terminal_title_stripped"], + ["workspace", "tab"], +] +``` + +上書きは `rows` を置き換えるもので、拡張するものではありません。上書きのキーには、`claude`、`codex`、`pi` のような大文字と小文字を区別する正規エージェント ID を使います。`claude-code` のような検出エイリアスは使えません。カスタム報告されたエージェントを含め、上書きがないエージェントには `rows` が使われます。 + +カスタム `$name` トークンは動的な値であり、リテラルテキストではありません。トークンをレイアウトに追加してから、スクリプトまたはプラグインで値を報告します: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "agent", "$model"], + ["$summary"], + ["workspace", "tab"], +] +``` + +```bash +herdr pane report-metadata <pane_id> \ + --source my-agent-hook \ + --token model=opus \ + --token summary="reviewing authentication" +``` + +カスタム Space トークンには、同じ方法で `herdr workspace report-metadata` を使います。報告されていないカスタムトークンは表示されません。 + +メタデータの報告元が提供できるのは値だけで、スタイルはローカルのサイドバー設定で管理します。上限、クリア、シーケンス、有効期限については、[CLI リファレンス: report metadata](/docs/cli-reference/#panes)を参照してください。 + +サイドバーの行設定は、展開されたデスクトップサイドバーにだけ適用されます。折りたたみ表示とモバイル表示はコンパクトなレイアウトを保ちます。 + +## 通知 + +Herdr は、バックグラウンドのエージェントが完了したり入力を必要としたりしたときに通知できます: + +```toml +[ui.toast] +delivery = "herdr" +delay_seconds = 1 + +[ui.toast.herdr] +position = "bottom-right" +``` + +アプリ内トーストには `herdr`、SSH 越しでも使いやすい外側のターミナル通知には `terminal`、ローカル OS の通知サービスには `system`、ポップアップを無効にするには `off` を選びます。Herdr はアクティブなタブのポップアップを抑制します。位置、遅延の挙動、クリップボードのフィードバック設定については、[設定リファレンス](/docs/config-reference/)で `ui.toast` を検索してください。 + +macOS では、`system` は最初に `terminal-notifier` を試し、利用できない場合や失敗した場合は `/usr/bin/osascript` にフォールバックします。このフォールバックは通知センターで Script Editor として表示され、ホストターミナルをアクティブにできません。`brew install terminal-notifier` で `terminal-notifier` をインストールできます。対応するターミナルを Herdr が検出できた場合、通知のクリック時にそのターミナルアプリをアクティブにできます。または、`terminal` を選んで対応する外側のターミナルに通知を送信させてください。 + +## サウンド + +サウンド通知はローカルの Herdr クライアントで再生されます。カスタムサウンドは mp3 ファイルでなければなりません。相対パスは設定ファイルのディレクトリから解決されます。 + +```toml +[ui.sound] +path = "sounds/notification.mp3" +done_path = "sounds/done.mp3" +request_path = "sounds/request.mp3" +``` + +`path` はすべてのサウンド通知に 1 つの音を設定します。`done_path` と `request_path` は、完了音と入力要求音だけを上書きします。 + +エージェント別のサウンド上書きには `default`、`on`、`off` を指定できます。キーには `claude`、`codex`、`devin`、`droid` のような検出されたエージェントラベルを使います。Droid はデフォルトでミュートされています。 + +```toml +[ui.sound.agents] +droid = "off" +claude = "on" +``` + +## 高度な設定 + +スクロールバックの上限、ネストされた起動、その他の高度な設定や実験的設定については、[設定リファレンス](/docs/config-reference/)を検索してください。ペイン画面履歴を有効にする前に、[セッション状態と復元](/docs/session-state/)を参照してください。このガイドでは、ペインの内容を保存する場合のセキュリティ上のトレードオフを説明しています。 + +## Kitty graphics + +Herdr は、対応する外側のターミナルでペイン画像をデフォルトで描画します。ポップアップ、メニュー、通知は、重なる画像の配置だけを一時的に隠します。重ならない画像は表示されたままで、隠れた配置は覆いがなくなると戻ります。画像はダイアログの背景と一緒に暗くなりません。 + +グラフィックス描画とペイングラフィックス API を無効にするには、次のように設定します: + +```toml +[terminal] +kitty_graphics = false +``` + +既存の設定との互換性のため、従来の `experimental.kitty_graphics` 設定も引き続き使用できます。両方が設定されている場合は、`terminal.kitty_graphics` が優先されます。 + +この設定を変更した場合は、対象の Herdr サーバーを再起動するか、クライアントを再アタッチする必要があります。リモートセッションでは、サーバー側の設定がペイングラフィックスの解析と API の可用性を制御し、ローカルクライアント側の設定が外側のターミナルへの出力を制御します。 + +## Agent セッション復元 + +Herdr はデフォルトで、サーバーの再起動後に対応する Agent の会話を再開します: + +```toml +[session] +resume_agents_on_restore = true +``` + +公式インテグレーションからの有効なネイティブセッション参照を持つペインだけが再開できます。その他のペインは通常のシェルとして復元されます。対応する Agent と永続化の挙動については、[セッション状態と復元](/docs/session-state/)を参照してください。 + +## IME カーソルトラッキング + +macOS では、ハードウェアカーソルを隠す AI Agent TUI によって、ネイティブ入力メソッドの変換候補ウィンドウがフォーカス中のペインを追跡できなくなることがあります。これらのペインでカーソルアンカーを表示するには、次のように設定します: + +```toml +[experimental] +reveal_hidden_cursor_for_cjk_ime = true +cjk_ime_agents = ["claude", "pi", "codex"] +``` + +`cjk_ime_agents` で対象を限定すると、関係のないアプリケーションに余分なハードウェアカーソルが表示されるのを避けられます。使用できる Agent 名とカーソル形状については、[設定リファレンス](/docs/config-reference/)でこれらのキーを検索してください。 + +## プレフィックス入力ソースの切り替え + +macOS と Windows では、プレフィックスコマンドとプレフィックスから起動したモードがアクティブな間、Herdr が ASCII 対応の入力ソースへ一時的に切り替えるようにできます: + +```toml +[experimental] +switch_ascii_input_source_in_prefix = true +``` + +macOS ではその時点の ASCII 対応キーボードレイアウトへ切り替えます。Windows では IME を英語(ASCII)入力へ切り替えます。ターミナル入力に戻るかテキストフィールドに入ると、Herdr は以前の入力ソースを復元します。この設定は他のプラットフォームには影響しません。 + +:::caution[Windows では韓国語 IME のみ対応] +Windows でのサポートは現時点で韓国語 IME に限定されています。それ以外の言語の IME を使用している場合、この設定は入力ソースを変更しません。 +::: + +## 環境変数 + +| 変数 | 目的 | +| --- | --- | +| `HERDR_CONFIG_PATH` | 設定ファイルのパスを上書きする。 | +| `HERDR_SESSION` | CLI コマンドで名前付きセッションを選択する。 | +| `HERDR_SOCKET_PATH` | 低レベルなソケットパスを上書きする。 | +| `HERDR_LOG` | ログフィルタリングを設定する。例: `HERDR_LOG=herdr=debug`。 | +| `HERDR_DISABLE_SOUND` | `[ui.sound] enabled = true` でも音の再生を無効にする。 | + +## ログ + +ログは、起動時の警告、インテグレーションの状態、ソケット API の挙動を診断するときに役立ちます。 + +主なログファイル: + +```text +~/.config/herdr/herdr.log +~/.config/herdr/herdr-client.log +~/.config/herdr/herdr-server.log +``` + +ログは自動的にローテーションされます。問題を報告するときは、現在のログとローテーションされた関連ファイルを含めてください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/connecting-machines.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/connecting-machines.mdx new file mode 100644 index 00000000..2c3a41cc --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/connecting-machines.mdx @@ -0,0 +1,108 @@ +--- +title: マシンへの接続 +description: Local と保存済み SSH マシンを 1 つの Herdr ウィンドウで操作し、エージェントをまとめて確認できます。各接続は独立して再接続します。 +--- + +ローカルの作業とリモートのエージェントを、1 つの Herdr ウィンドウにまとめられます。SSH マシンを一度保存すれば、別のクライアントを開かずに、そのワークスペースと Local を切り替えられます。エージェント一覧には接続中のマシンが含まれ、作業場所や回答待ちのエージェントを確認できます。 + +各マシンは独自の Herdr サーバー、セッション、プロセスを保持します。1 台への接続が切れても、ほかの接続は切れません。 + +## 接続前の確認 + +リモートマシンへの通常の SSH アクセスが必要です。最初に確認してください: + +```bash +ssh workbox +``` + +`workbox` は SSH config のホスト名です。`ssh://you@server:2222` のような接続先も使えます。 + +複数マシンへの接続は Linux、macOS、Windows のクライアントに対応し、x86_64 または aarch64 の Linux と macOS サーバー、および x86_64 の Windows サーバーに接続できます。対話型セットアップでは確認後に完全なリモートパッケージをインストールまたは更新できます。バックグラウンド再接続ではインストール済みのパッケージだけを探します。SSH 設定、認証、カスタムバイナリについては [SSH 越しのリモートアタッチ](/ja/docs/persistence-remote/)を参照してください。 + +## マシンを追加する + +インストールや置き換えの前に確認できるよう、対話型ターミナルで実行します: + +```bash +herdr machine add workbox --label "Build machine" +``` + +リモートのデフォルトセッションを使います。1 つのプロファイルは 1 つのリモートセッションを対象とし、そのホストの全セッションをまとめるものではありません。 + +名前付きセッションを使いたい場合だけ、任意の `--remote-session` を追加します: + +```bash +herdr machine add workbox --label "Build machine" --remote-session agents +``` + +Herdr はインストール済みバイナリと実行中サーバーをそれぞれ確認し、対象のバックグラウンドサーバーを起動してからプロファイルを保存します。互換性があればクライアントとサーバーのバージョンは一致しなくても構いません。未インストールや非互換の場合は、承認付きのセットアップを行います。実行中サーバーの置き換えが必要なら、サーバーとペインのプロセスを停止する前に確認し、その後に互換サーバーを起動します。デフォルトの回答は No です。インストールと置き換えの両方が必要な場合は、1 回の確認で承認します。`machine add` は実験的なライブハンドオフを使いません。セットアップをキャンセルした場合や失敗した場合、プロファイルは保存されません。 + +`herdr` で UI を開きます。ローカルクライアントがすでに開いていれば、追加や有効化したマシンは通常 1 秒以内に表示され、選択を変えずにバックグラウンドで接続します。マシン切り替え中の変更は、切り替えの完了後に反映します。セットアップ終了後もリモートサーバーは動き続けます。 + +## マシンを切り替える + +サイドバーでマシンまたはそのワークスペースを選びます。選択中のマシンだけがペイン入力とターミナルサイズを受け取り、表示するターミナル内容とグラフィックスを送信します。ほかの接続済みマシンは、ペイン画面を送らずにワークスペース情報、エージェント状態、通知を更新し続けます。 + +各マシンの横の矢印をクリックすると、現在のワークスペースを切り替えずに一覧を折りたためます。再接続中のマシンでも操作できます。 + +キーボードでは `prefix+w` を押し、ワークスペース移動キー(デフォルトは Up/Down)で接続済みマシンのワークスペースをサイドバー順に選びます。Enter で移動を確定し、Esc またはプレフィックスキーで切り替えずにキャンセルします。縮小・展開サイドバーとも、折りたたまれたマシン内の選択行を表示します。デスクトップでは両端で折り返し、モバイルの切り替え画面では先頭と末尾で止まります。切断中のマシンは飛ばします。 + +別マシンのワークスペースをハイライトしたら、他のキー操作の前に Enter で切り替えてください。現在のマシンでペイン、タブ、ワークスペース、カスタムコマンドの操作が誤って実行されるのを防ぎます。クリックすると、デスクトップでのリモート移動のプレビューはキャンセルされます。展開サイドバーはマシンごとの worktree グループの折りたたみ状態を反映します。 + +起動時は SSH 接続を待たずに Local を開きます。Local を選ぶと、リモートの応答を待たずに未完了の切り替えをキャンセルします。Local 自体が再接続中なら、準備ができた時点で選択を再開します。Local の新しい画面とターミナル設定が届くと入力できます。応答しないマシンがほかのマシンの入力を妨げることはありません。同じサーバーに接続した複数のクライアントは、それぞれ別のタブを表示できます。同じタブを表示した場合のサイズについては[クライアントとサーバー](/ja/docs/concepts/)を参照してください。 + +接続が切れると、最後のワークスペースとエージェント状態を暗く表示します。これはキャッシュであり、現在の状態ではありません。新しい接続と対応する画面が届くまで、そのペインへの入力や移動は無効です。再接続しても、使用中のマシンから選択を奪いません。 + +## 名前の変更、無効化、削除 + +ラベルやホスト名から推測せず、一覧からプロファイル ID を取得します: + +```bash +herdr machine list +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +スクリプトでは `machine list` に `--json` を追加できます。 + +名前の変更は表示ラベルだけを更新し、再接続しません。無効化はプロファイルを残し、削除はプロファイルを忘れます。どちらもクライアントからそのマシンだけを切断し、到達不能なホストでも操作できます。リモートセッションとエージェントは動き続けます。 + +表示中のマシンを削除または無効化すると Local に戻ります。Local が利用できない場合は、別のリモートを選ばず、その状態を表示して再接続します。有効な保存済みマシンがあれば、Local が失敗または再起動してもクライアントを使い続けられます。 + +## 接続の問題 + +- **Reconnecting:** ネットワーク中断、スリープ、SSH 障害の後は自動で再試行します。失敗が続くと待機間隔は最大 2 分まで延び、短時間だけ接続に成功してもリセットされません。1 分間安定して接続できると、次の中断では再び短い間隔で再試行します。SSH 接続はアプリケーションの通信を監視し、通信がない間はヘルスチェックを行うため、切れた接続が Online のままになりません。Local はリモート用のプローブではなく、ネイティブ接続の終了や失敗を検知します。 +- **Attention:** ホスト鍵の承認、認証、互換サーバーの準備など、バックグラウンドではできない操作が必要です。ほかのマシンは利用できます。 +- **保存済みマシンのファイルエラー:** カタログを読み取れない、または内容が不正な場合、現在の接続は変更しません。通知を表示して自動で読み取りを再試行します。 + +両端のインストールがブリッジのアイドル終了に対応している場合、Linux と macOS への保存済みマシン接続は、どちらの方向にも通信がない状態が 1 分続くとリモートブリッジを終了します。スリープ時間も含み、復帰時に期限を確認します。正常なアイドル接続ではヘルスチェックを交換するため、継続的な出力を見るためにキーを入力する必要はありません。ブリッジ終了後もリモートサーバー、セッション、ペインのプロセスは動き続けます。古いインストールとは、この追加機能なしで接続できます。 + +バックグラウンド接続がプロンプトに回答したり、サーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。Attention の場合は、Herdr が表示する単独接続用のコマンドを対話型ターミナルで実行してください。例: + +```bash +herdr --remote workbox +``` + +プロファイルの接続先を使います。追加時に名前付きセッションを選んだ場合だけ、`--session <name>` も指定します。必要な確認に回答したら、クライアントを再起動して Attention の接続を再試行してください。バージョンが違うという理由だけで実行中サーバーを止めないでください。 + +認証に失敗したら、まず通常の SSH を確認します。パスフレーズ付きの鍵は、非対話型のバックグラウンド接続を始める前に `ssh-add` で読み込んでください。 + +## 設定と自動化 + +UI はデフォルトでクライアントのローカルテーマ、サイドバー設定、キーバインドを使います。選択中サーバーが公開するカスタムコマンドとプラグインは、そのサーバーで実行します。Herdr はローカルのプラグイン、設定、実行ファイル、シークレットを SSH ホストにコピーしません。リモートにコマンドがなければエラーを表示します。クライアント設定を編集したら UI の `reload config` を使います。[設定](/ja/docs/configuration/)を参照してください。 + +複数のマシンがある場合、デフォルトの Agent 行に `machine` トークンを表示します。既存のカスタム行は維持されるため、ラベルが必要なら `machine` を追加してください。[サイドバーの行レイアウト](/ja/docs/configuration/)ではマシンラベルの条件付き色も設定できます。 + +ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。2 台に同じ `w1:p1` や `reviewer` が存在する場合があります。UI でマシンを選んでも、既存ペイン内の CLI は継承したセッションとソケットを使い続けます。リモート自動化では `herdr --machine <label-or-id> agent list` で ID を取得し、操作時にも同じプレフィックスを指定します。CLI は保存済みプロファイルの SSH 接続先とセッションを直接使うため、TUI を開く必要はありません。`--machine` を省略した場合の動作は変わりません。対応コマンド、更新要件、パスの扱いは [CLI リファレンス](/ja/docs/cli-reference/#保存済み-ssh-マシン) を参照してください。 + +## 更新と保存データ + +保存するのは、不透明な ID、ラベル、SSH 接続先、明示的なリモートセッション名、有効状態だけです。パスワード、秘密鍵、エージェントのチケット、SSH 制御ソケットはカタログに保存しません。認証は OpenSSH が扱います。 + +クライアントとサーバーは同一バージョンを要求せず、互換性を合意します。保存済みマシンへの接続には、サーバーの `surface_interest` と `health_check` 能力も必要です。未対応の古いサーバーは、単独接続できても明示的に更新するまで Attention になります。そのほかの未対応メソッドは、該当する操作だけを無効にします。 + +互換クライアントを更新しても、リモートサーバーを置き換えたりエージェントを停止したりしません。新しいサーバー機能が必要なら、そのサーバーを明示的に更新します。通常の置き換えでは、サーバーとペインのプロセスを停止する前に確認します。 + +ライブハンドオフは実験的なオプトイン機能です。単独接続のセットアップで対応サーバーを置き換える場合、`herdr --remote` に `--handoff` を明示的に追加できます。通常の接続や認証の修復には不要です。[更新](/ja/docs/install/)と[セッション状態と復元](/ja/docs/session-state/)で、それぞれの操作が何を維持するか確認してください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/how-to-work.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/how-to-work.mdx new file mode 100644 index 00000000..d3049589 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/how-to-work.mdx @@ -0,0 +1,103 @@ +--- +title: Herdr での作業の進め方 +description: Herdr をローカルで、SSH 内で、またはリモートアタッチで使います。 +--- + +Herdr は作業がある場所で動かし、どこからでもアタッチします。 + +Herdr はバックグラウンドのセッションサーバーと、1 つ以上のターミナルクライアントで構成されます。ペインはサーバー内で動き続けます。クライアントはアタッチ、デタッチし、セッションを描画します。 + +## ローカルでの作業 + +プロジェクトディレクトリから Herdr を起動します: + +```bash +herdr +``` + +Herdr はローカルのバックグラウンドセッションを自動的に起動するか、それにアタッチします。ソケットを管理する必要はありません。ペインの中でシェル、サーバー、テスト、エージェントを普段どおり実行してください。 + +`ctrl+b q` でクライアントをデタッチします。ペインは動き続けます。 + +あとで再アタッチします: + +```bash +herdr +``` + +セッションを終了してペインを停止したい場合は、サーバーを停止します: + +```bash +herdr server stop +``` + +## 通常の SSH 越しのリモート作業 + +コードと認証情報のあるマシンに SSH して、そこで Herdr を実行します: + +```bash +ssh you@server +herdr +``` + +これはターミナルマルチプレクサと同じように動作します。シェルはリモート、Herdr サーバーもリモート、エージェントとペインはリモートマシン上で動きます。`ctrl+b q` でデタッチして切断し、あとで SSH し直して再び `herdr` を実行してください。 + +すでに SSH シェルの中で作業している場合、スマートフォンやタブレットの SSH クライアントを使っている場合、あるいは最もシンプルな構成にしたい場合はこの方法を使ってください。 + +## スマートフォンから作業する + +Herdr のモバイルアプリや Web ダッシュボードは不要です。スマートフォンに任意の SSH クライアントを入れ、エージェントが動いているマシンに接続して、そこで Herdr を起動するだけです: + +```bash +ssh you@server +herdr +``` + +同じ永続 Herdr セッションがスマートフォンのターミナルに開きます。TUI は狭い画面に適応するので、SSH から離れることなくエージェントの確認、ワークスペースの切り替え、ペインのチェックができます。 + +iPhone では [moshi](https://getmoshi.app/) のようなアプリがよく動作します。 + +<div class="mobile-doc-shots"> + <figure> + <img src="/assets/mobile-agent-session-v2.jpeg" alt="スマートフォンで SSH 越しに表示した Herdr のエージェントセッション" loading="lazy" /> + <figcaption>SSH 越しのエージェントセッション</figcaption> + </figure> + <figure> + <img src="/assets/mobile-switch-menu-v2.jpeg" alt="スマートフォンで表示した Herdr のレスポンシブ切り替えメニュー" loading="lazy" /> + <figcaption>レスポンシブな切り替えメニュー</figcaption> + </figure> +</div> + +## ローカルターミナルからのリモート作業 + +先にシェルを開かずに SSH 越しでアタッチします: + +```bash +herdr --remote workbox +herdr --remote ssh://you@server:2222 +``` + +リモートサーバーがペインを動かし、ターミナル内容とセッション状態を SSH 越しに送信します。ローカルの Herdr が、ローカルのテーマや表示設定を使って UI を描画します。 + +リモートセッションをローカルのように感じたいときはこの方法を使ってください。クライアントは手元のマシンで動くので、画像クリップボードの貼り付けのようなローカルデスクトップ機能をリモートサーバーにブリッジできます。先に SSH してサーバー上で `herdr` を実行した場合、Herdr は完全にそのサーバー上で動くため、ローカルデスクトップのクリップボードは読めません。 + +繰り返し接続する相手は SSH config に登録しておきましょう: + +```text +Host workbox + HostName server.example.com + User you + Port 2222 +``` + +その後はこれでアタッチできます: + +```bash +herdr --remote workbox +``` + +## どの方法を使うか + +ローカル作業には `herdr` を使います。リモートシェル上で Herdr を tmux のように使いたいときや、スマートフォンの SSH クライアントを使っているときは `ssh you@server` してから `herdr` を使います。単独のリモートセッションをローカル UI で操作し、ローカルクリップボードの画像も貼り付けたい場合は `herdr --remote <host>` を使います。Local と複数の SSH マシンを 1 つのウィンドウで使うには、`herdr machine add <host> --label <label>` で接続先を保存します。[マシンへの接続](/ja/docs/connecting-machines/)を参照してください。 + +リモートブートストラップの詳細、名前付きリモートセッション、カスタムバイナリ、ダイレクトターミナルアタッチについては[永続化とリモートアクセス](/ja/docs/persistence-remote/)を参照してください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/index.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/index.mdx new file mode 100644 index 00000000..ea50b0d7 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/index.mdx @@ -0,0 +1,82 @@ +--- +title: Herdr ドキュメント +description: AI コーディングエージェントのためのターミナルワークスペースマネージャー。 +template: splash +hero: + tagline: "Herdr のインストール、学習、設定。あなたに合ったパスから始めましょう — マルチプレクサの経験は不要です。" + image: + file: ../../../../public/assets/logo.svg + actions: + - text: Herdr をインストール + link: /ja/docs/install/ + - text: クイックスタート + link: /ja/docs/quick-start/ + variant: secondary +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + +## パスを選ぶ + +<CardGrid> + <Card title="ターミナルマルチプレクサは初めて?"> + ショートカットを覚えなくても始められます。Herdr はマウスファーストです。ペインをクリックし、境界をドラッグし、右クリックメニューから分割や切り替えができます。 + + [クイックスタート →](/ja/docs/quick-start/) + </Card> + <Card title="tmux や zellij から乗り換え?"> + モデルはすでにご存じのとおりです。プレフィックスは `ctrl+b`、ペインは永続化され、デタッチと再アタッチは期待どおりに動作します。 + + [コンセプト →](/ja/docs/concepts/) · [キーバインド →](/ja/docs/configuration/#キーバインド) + </Card> +</CardGrid> + +## またはエージェントに案内してもらう + +すでに AI コーディングエージェントを使っていますか? オンボーディングはエージェントに任せましょう。このプロンプトを貼り付けてください: + +```text +Help me understand and set up Herdr. Read https://herdr.dev/agent-guide.md first, then walk me through it step by step. +``` + +このガイドはエージェントに Herdr のコンセプト、セットアップ、設定、よくある問題の解決方法を教えるので、エージェントの回答が即興ではなく正確なままになります。 + +## コアガイド + +<CardGrid> + <Card title="エージェント"> + 対応エージェント、検出の挙動、インテグレーション、カスタムラベル、ダイレクトアタッチについて。 + + [エージェントを理解する →](/ja/docs/agents/) + </Card> + <Card title="マシンへの接続"> + Local と保存済み SSH マシンを 1 つのウィンドウにまとめ、エージェントを一覧できます。接続はそれぞれ独立して再接続します。 + + [マシンを接続する →](/ja/docs/connecting-machines/) + </Card> + <Card title="セッション状態"> + デタッチ、再起動時の復元、ペイン履歴のリプレイ、エージェントネイティブの resume、ライブハンドオフについて。 + + [状態管理の方式を比較する →](/ja/docs/session-state/) + </Card> + <Card title="設定"> + キーバインド、テーマ、サイドバーの挙動、通知、スクロールバック、高度なオプションを設定します。 + + [Herdr を設定する →](/ja/docs/configuration/) + </Card> + <Card title="API"> + CLI とローカルソケット API を通じて、スクリプト・ツール・エージェントから Herdr を制御します。 + + [API ガイドを読む →](/ja/docs/socket-api/) + </Card> + <Card title="プラグイン"> + マニフェストのアクションとイベントフックを備えた、ローカル実行型ワークフロープラグインを作成します。 + + [プラグインを書く →](/ja/docs/plugins/) + </Card> + <Card title="マーケットプレイス"> + 今すぐ GitHub からプラグインを共有し、リポジトリにトピックを付けてマーケットプレイス公開時に掲載されるようにしましょう。 + + [プラグインを公開する →](/ja/docs/marketplace/) + </Card> +</CardGrid> diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/install.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/install.mdx new file mode 100644 index 00000000..8217d311 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/install.mdx @@ -0,0 +1,154 @@ +--- +title: Herdr のインストール +description: Linux、macOS、Windows での Herdr のインストール、アップデート、動作確認。 +--- + +Herdr は Linux、macOS、Windows 向けに安定版チャンネルのバイナリを提供しています。Windows 対応は正式提供されており、プラットフォーム固有の既知の制限と継続的な修正があります。 + +## インストール + +Linux または macOS では次を実行します: + +```bash +curl -fsSL https://herdr.dev/install.sh | sh +``` + +Windows では次を実行します: + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex" +``` + +エンドポイントセキュリティによってこのファイルレス PowerShell コマンドがブロックされる場合は、コマンドプロンプトを開いて次を実行します: + +```cmd +curl.exe -fsSLo install.cmd https://herdr.dev/install.cmd && install.cmd && del install.cmd +``` + +インストーラーはプラットフォームに合ったリリースバイナリをダウンロードして PATH 上に配置します。新しい直接インストールは安定版アップデートチャンネルを使います。既存の Windows プレビューインストールは、`herdr channel set stable` で切り替えるまでプレビューに残ります。Windows では PATH が有効なバージョン付きリリースディレクトリを指し、インストーラーは互換性のため `current` と安定した `bin` エイリアスも更新します。そのため、アップデート時に実行中の `herdr.exe` を上書きする必要がありません。 + +## Homebrew でインストール + +すでに Homebrew を使っている場合: + +```bash +brew install herdr +``` + +## mise でインストール + +すでに mise を使っている場合: + +```bash +mise use -g herdr +``` + +mise が `herdr not found in mise tool registry` と報告する場合は、mise をアップデートして再試行してください。古い mise は Herdr のレジストリ登録より前のバージョンです。一時的な回避策として `mise use -g github:herdrdev/herdr` も使えます。 + +## Nix でインストール + +すでに Nix を使っている場合、Herdr はソースからビルドする flake を提供しています: + +```bash +nix run github:herdrdev/herdr/v0.x.y +nix build github:herdrdev/herdr/v0.x.y +nix profile install github:herdrdev/herdr/v0.x.y +``` + +`v0.x.y` は最新のリリースタグに置き換えてください。タグを省略すると `master` を追跡できますが、通常のインストールにはリリースタグを推奨します。 + +flake は開発用シェルも公開しています: + +```bash +nix develop github:herdrdev/herdr +``` + +アップデートは、Herdr のインストールに使ったのと同じ Nix ワークフローで行います。プロファイルインストールの場合は、プロファイルのエントリを一覧して Herdr のエントリをアップグレードします: + +```bash +nix profile list +nix profile upgrade <index-or-name> +``` + +Herdr が自分の flake の input になっている場合は、その input を更新してシステム、Home Manager、または開発環境を再ビルドします: + +```bash +nix flake update herdr +``` + +## 手動でダウンロード + +[GitHub releases](https://github.com/herdrdev/herdr/releases) からバイナリをダウンロードすることもできます。 + +システムに合ったアセットを選んでください: + +| システム | アセット | +| --- | --- | +| Linux x86_64 | `herdr-linux-x86_64` | +| Linux aarch64 | `herdr-linux-aarch64` | +| macOS Intel | `herdr-macos-x86_64` | +| macOS Apple silicon | `herdr-macos-aarch64` | +| Windows x86_64 | `herdr-windows-x86_64.zip` | + +Linux または macOS では、実行可能にして PATH 上のどこかに移動します。 + +```bash +chmod +x herdr-linux-x86_64 +mv herdr-linux-x86_64 ~/.local/bin/herdr +``` + +### Windows アーカイブ + +安定版リリースとプレビュープレリリースの両方に `herdr-windows-x86_64.zip` が含まれます。アーカイブには `herdr.exe` とアプリローカルの ConPTY ランタイムが含まれます。展開したディレクトリをまとめて保持し、`herdr.exe` だけをコピーしないでください。 + +## 動作確認 + +Herdr を起動します: + +```bash +herdr +``` + +シェルが `herdr` を見つけられない場合は、ターミナルを再起動するか、インストール先ディレクトリが PATH に含まれているか確認してください。 + +## アップデート + +Herdr は新しいリリースをチェックし、アプリ内で通知します。手動でアップデートすることもできます: + +```bash +herdr update +``` + +`herdr update` は Herdr 自身のインストーラーで管理されているインストール向けです。Homebrew、mise、Nix のインストールは、それぞれのパッケージマネージャーでアップデートしてください。 + +Linux、macOS、Windows の直接インストールは、デフォルトで安定版アップデートチャンネルを使います。`master` からのプレビュービルドにオプトインするには、チャンネルを設定します: + +```bash +herdr channel set preview +``` + +直接インストールを安定版に戻すのも同じ方法です: + +```bash +herdr channel set stable +``` + +直接インストールの場合、チャンネルの変更はそのチャンネルをチェックして最新バイナリをインストールします。そのアップデートが失敗した場合は、`herdr update` を実行して設定済みチャンネルから再試行してください。 + +プレビュービルドは、現在の開発ブランチから定期的に公開される GitHub プレリリースです。次の安定版リリースより前に修正が欲しいときに便利ですが、リグレッションの可能性があります。Homebrew、mise、Nix のインストールはプレビューチャンネルを使いません。 + +Windows の通常利用には安定版を推奨します。プレビューでは修正をより早く受け取れますが、リグレッションの可能性があるため、そのトレードオフを望む場合に限りオプトインしてください。既存の Windows プレビューインストールは、明示的に切り替えるまでプレビューに残ります。古いプレビュービルドで `herdr channel set stable` が拒否される場合は、プレビューのまま一度 `herdr update` を実行してから、チャンネル切り替えを再試行してください。 + +デフォルトでは、`herdr update` は新しいバイナリをインストールし、エンドポイント世代 1 のサーバーとペインプロセスを動かしたままにします。再度 Herdr を起動すると、更新済みクライアントで再接続します。クライアントだけの変更はすぐに使えます。新しいサーバー側アクションは、そのサーバーを更新して再起動するまで利用できず、クライアントは切断せずに通知を表示します。エンドポイント世代 1 より前のサーバーだけは、更新時に最後の 1 回の停止が必要です。 + +対応する実行中セッションで実験的なライブサーバーハンドオフにオプトインするには: + +```bash +herdr update --handoff +``` + +ライブハンドオフは Homebrew、mise、Nix のパッケージマネージャー経由のアップデートには適用されません。それらのインストールではパッケージマネージャーで更新し、再度 Herdr を実行して更新済みクライアントで再接続します。互換性のある古いサーバーは動き続けます。新しいリリースのサーバー側変更が必要になったときだけ、`herdr server stop` または `herdr session stop <name>` で後から再起動してください。 + +## 動作要件 + +安定版チャンネルのバイナリは Linux、macOS、Windows x86_64 で利用できます。サポートされるワークフローと既知の制限は [Windows サポート](/ja/docs/windows-beta/)を参照してください。Windows ARM64 では x86_64 ビルドが Windows のエミュレーション上で動作します。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/integrations.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/integrations.mdx new file mode 100644 index 00000000..6086e6b6 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/integrations.mdx @@ -0,0 +1,400 @@ +--- +title: インテグレーション +description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 +--- + +Herdr は対応エージェントを自動的に検出します。公式インテグレーションは、復元のためのネイティブセッション識別、ライフサイクル状態の報告、またはその両方を追加できます。 + +エージェントネイティブのセッション復元、直接のライフサイクル報告、またはその両方が欲しいときにインテグレーションを使ってください。状態権威モデルの全体像は[エージェント](/ja/docs/agents/)を参照してください。 + +## インテグレーションをインストールする + +Herdr 内で設定を開き、インテグレーションタブから `PATH` 上で見つかったエージェント向けの推奨インテグレーションをインストールするか、コマンドを手動で実行します: + +```bash +herdr integration install pi +herdr integration install omp +herdr integration install claude +herdr integration install codex +herdr integration install copilot +herdr integration install devin +herdr integration install droid +herdr integration install kimi +herdr integration install opencode +herdr integration install kilo +herdr integration install hermes +herdr integration install mastracode +herdr integration install qodercli +herdr integration install qwen +herdr integration install letta +herdr integration install cursor +herdr integration install antigravity-cli +herdr integration install grok +``` + +## インテグレーションをアンインストールする + +```bash +herdr integration uninstall pi +herdr integration uninstall omp +herdr integration uninstall claude +herdr integration uninstall codex +herdr integration uninstall copilot +herdr integration uninstall devin +herdr integration uninstall droid +herdr integration uninstall kimi +herdr integration uninstall opencode +herdr integration uninstall kilo +herdr integration uninstall hermes +herdr integration uninstall mastracode +herdr integration uninstall qodercli +herdr integration uninstall qwen +herdr integration uninstall letta +herdr integration uninstall cursor +herdr integration uninstall antigravity-cli +herdr integration uninstall grok +``` + +Herdr は Linux/macOS の統合設定と全プラットフォームの新規ファイルをアトミックに保存し、Windows の既存ファイルは権限を維持するため、バックアップ後に同じファイルを更新します。Windows でバックアップが通知された場合は、再試行前にエージェントを終了して設定を確認し、未完了の `.herdr-backup.pending` は復旧元に使わず設定が正しいことを確認してから削除するか、必要なら完成済みの `.herdr-backup` の内容を既存ファイルに書き戻し(ファイルがない場合は先に意図した権限を設定)、設定が正しいことを確認してからバックアップを削除してください。 + +## Herdr がインテグレーションをどう使うか + +Herdr はインテグレーションを 2 つの異なる方法で使います: + +| インテグレーションの種類 | エージェント | 効果 | +| --- | --- | --- | +| ライフサイクル権威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | インストールされ、そのペインについて能動的に報告している間は、フックまたはプラグインのイベントが `idle`、`working`、`blocked` を決定します。同じライフサイクル権威に対して、Herdr はスクリーンマニフェストのフォールバックを併用しません。 | +| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | + +カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。 + +## 独自エージェントを統合する + +Herdr ペイン内で動くエージェントは、`HERDR_ENV`、`HERDR_PANE_ID`、`HERDR_BIN_PATH`、`HERDR_SOCKET_PATH` を継承します。エージェントがライフサイクルフックを提供している場合は、そのフックから Herdr の CLI を使ってセマンティックな状態を報告します: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source custom:my-agent \ + --agent my-agent \ + --state working +``` + +エージェントが入力待ちになったら `idle`、ユーザーの判断が必要になったら `blocked` を報告します。ブロックの説明には `--message` を使います。エージェントの終了時には、同じ source のライフサイクル権威を解放します: + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source custom:my-agent \ + --agent my-agent +``` + +Herdr の外では何もしないように、`HERDR_ENV=1` で必要な変数が存在するときだけ報告してください。`--source` はインテグレーションごとに一意かつ固定にします。報告が順不同で届く可能性がある場合は、単調増加する `--seq` を含めてください。Herdr は同じ source から届いた古いシーケンス番号を無視します。 + +`report-agent` に `--agent-session-id` または `--agent-session-path` を含めることも、セッション識別が状態とは独立して変わる場合に `pane report-agent-session` を使うこともできます。Herdr はその参照をペイン API とエージェント API で公開します。セッションの自動復元には、それに加えて、そのエージェントの起動方法と参照先セッションの再開方法を Herdr が把握している必要があります。 + +移植可能なインテグレーションには `HERDR_BIN_PATH` と CLI ラッパーを使ってください。直接 IPC が必要なコードでは、[Socket API](/ja/docs/socket-api/) に記載された同等の `pane.report_agent`、`pane.report_agent_session`、`pane.release_agent` リクエストを送信できます。 + +[Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際の実装例です。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時に権威を解放します。 + +一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 + +エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Letta Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `2`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 + +## Pi + +Pi インテグレーションをインストールします: + +```bash +herdr integration install pi +``` + +Herdr はバンドルされた拡張を次の場所に書き込みます: + +```text +~/.pi/agent/extensions/herdr-agent-state.ts +``` + +`PI_CODING_AGENT_DIR` が設定されている場合は、代わりに `$PI_CODING_AGENT_DIR/extensions/herdr-agent-state.ts` に書き込みます。Pi の agent ディレクトリが存在する場合、Herdr は extensions ディレクトリを作成します。アンインストールはその拡張ファイルだけを削除します。 + +## OMP + +OMP インテグレーションをインストールします: + +```bash +herdr integration install omp +``` + +Herdr はバンドルされた拡張を次の場所に書き込みます: + +```text +~/.omp/agent/extensions/herdr-omp-agent-state.ts +``` + +`PI_CODING_AGENT_DIR` が設定されている場合は、代わりに `$PI_CODING_AGENT_DIR/extensions/herdr-omp-agent-state.ts` に書き込みます。OMP の agent ディレクトリが存在する場合、Herdr は extensions ディレクトリを作成します。アンインストールはその拡張ファイルだけを削除します。 + +OMP インテグレーションは、Herdr のソケット API を通じてエージェントラベル `omp`、ライフサイクル状態、ネイティブセッション識別を報告します。`omp` 実行ファイルのネイティブプロセス検出は不要で、Herdr はサーバー再起動後に `omp --resume=<session>` で OMP ペインを resume できます。 + +## Claude Code + +Claude Code フックをインストールします: + +```bash +herdr integration install claude +``` + +このフックは、セッション開始時に Claude Code のセッション識別をローカルの Herdr ソケットに報告します。Claude Code の状態は Herdr のスクリーンマニフェスト検出から得られます。 + +Claude 統合 v10 は、文書化された `SessionStart` の source 値 `startup`、`resume`、`clear`、`compact`、`fork` のみに一致します。これにより、ほかの Claude 互換フックを無効にせずに、Grok の `new` と `load` イベントがインポートされた Herdr の Claude フックを起動するのを防ぎます。アップグレード後に Claude 統合を再インストールして、従来のワイルドカードマッチャーを移行してください。Claude に新しい source 値が追加された場合は、統合の更新が必要です。 + +Herdr はデフォルトで `~/.claude` を使い、`CLAUDE_CONFIG_DIR` が設定されていればそちらを使います。Claude の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh` を書き込み、`settings.json` に Herdr のフックエントリを追加します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 + +## Codex + +Codex フックをインストールします: + +```bash +herdr integration install codex +``` + +Codex フックは、他のインテグレーションと同じローカルソケット API を通じてセッション識別を報告します。Codex の状態は Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr はデフォルトで `~/.codex` を使い、`CODEX_HOME` が設定されていればそちらを使います。Codex の設定ディレクトリはあらかじめ存在している必要があります。インストールは `herdr-agent-state.sh` を書き込み、`hooks.json` を更新し、`config.toml` に `[features] hooks = true` があることを保証します。非推奨のトップレベル `codex_hooks` フラグが存在すれば削除もします。アンインストールは `hooks.json` から Herdr のエントリを削除してフックスクリプトを削除しますが、`config.toml` は変更しません。 + +## GitHub Copilot CLI + +GitHub Copilot CLI フックをインストールします: + +```bash +herdr integration install copilot +``` + +Copilot フックは、他のインテグレーションと同じローカルソケット API を通じてセッション識別を報告します。Copilot の状態は Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr はデフォルトで `~/.copilot` を使い、`COPILOT_HOME` が設定されていればそちらを使います。Copilot の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh` を書き込み、`settings.json` に `SessionStart` フックエントリを追加します。アンインストールは `settings.json` から Herdr のエントリを削除し、フックスクリプトを削除します。 + +Copilot がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `copilot --resume=<id>` でペインを resume できます。 + +## Devin CLI + +Devin CLI フックをインストールします: + +```bash +herdr integration install devin +``` + +このフックは、Devin のセッション、プロンプト、ツール使用、許可、停止の各イベントからネイティブセッション識別を報告します。Devin のフックはすべての許可キャンセルやユーザー割り込みの後に信頼できる状態遷移を発行しないため、Devin の状態は引き続き Herdr のスクリーンマニフェストと OSC 検出から得られます。 + +`XDG_CONFIG_HOME` が設定されていれば、Herdr は `$XDG_CONFIG_HOME/devin` を使います。それ以外では、Linux と macOS では `~/.config/devin`、Windows では `%APPDATA%\devin` を使います。Devin の設定ディレクトリはあらかじめ存在している必要があります。インストールは Unix では `herdr-agent-state.sh`、Windows では `herdr-agent-state.ps1` を書き込み、`config.json` に Herdr のフックエントリを追加します。フックは Devin の実行中にセッション参照を更新します。アンインストールは `config.json` から Herdr のエントリを削除し、フックスクリプトを削除します。 + +Herdr は保存された Devin セッションを `devin --resume <id>` で resume します。フックのインストール有無にかかわらず、スクリーンマニフェスト検出が状態の権威のままです。 + +## Kimi Code CLI + +Kimi Code CLI フックをインストールします: + +```bash +herdr integration install kimi +``` + +このフックは、ネイティブ復元と権威ある `idle`、`working`、`blocked` 状態のために、Kimi のセッション識別とライフサイクル状態を Herdr に報告します。Kimi Code CLI `0.14.0` 以上が必要です。 + +Herdr はデフォルトで `~/.kimi-code` を使い、`KIMI_CODE_HOME` が設定されていればそちらを使います。Kimi Code の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh` を書き込み、`config.toml` に Herdr 管理の `[[hooks]]` エントリを追記します。アンインストールは Herdr 管理の設定ブロックを削除し、フックスクリプトを削除します。 + +Herdr は保存された Kimi セッションを `kimi --session <id>` で resume します。 + +## Droid + +Droid フックをインストールします: + +```bash +herdr integration install droid +``` + +Droid フックは、他のインテグレーションと同じローカルソケット API を通じてセッション識別を報告します。Droid のフックはすべてのライフサイクル遷移をカバーしていないため、ライフサイクル状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr は Droid フックに `~/.factory` を使います。Factory の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh` を書き込み、`settings.json` に Herdr の `SessionStart` フックエントリを追加し、`hooks.json` に古い Herdr Droid フックエントリがあれば削除します。アンインストールは両方の設定ファイルから Herdr のエントリを削除し、フックスクリプトを削除します。 + +Droid がセッション開始イベントを発行した後、Herdr は報告されたセッション id を使って `droid --resume <id>` でペインを resume できます。 + +## OpenCode + +OpenCode プラグインをインストールします: + +```bash +herdr integration install opencode +``` + +この連携は OpenCode V1 `1.18.29` 以降と OpenCode V2(beta `19242` で検証済み)に対応します。OpenCode の設定ディレクトリはあらかじめ存在している必要があります。Herdr はサーバー側のエントリポイントを `~/.config/opencode/plugins/herdr-agent-state.js` に、共通 TUI プラグインを同じ設定ディレクトリの `herdr-tui-session.js` に、V2 TUI エントリポイントを `herdr-opencode/tui.js` にインストールします。 + +インストール時、V1 TUI プラグインを `tui.jsonc` に、V2 TUI プラグインを `cli.json` に登録し、他の設定やプラグインは保持します。OpenCode に移行すべき V1 TUI 設定(`tui.json` または `kv.json`)が残っている場合、Herdr は初回起動時の移行を妨げないよう登録を見送ります。その場合は一度 `opencode2` を起動してから連携を再インストールしてください。それ以外の場合は Herdr が `cli.json` を作成してプラグインを登録します。インストール後は OpenCode TUI を再起動してください。アンインストールは管理対象のプラグインファイルと設定項目を削除します。 + +V2 のライフサイクル報告はペイン内の TUI で実行され、複数のペインが同じ OpenCode サーバーを共有する場合も、選択されたルートセッションにイベントを対応付けます。完了時または中断時に working 状態を解除し、未処理の権限要求やフォーム、実行失敗は blocked 状態を維持します。V2 Mini とヘッドレスクライアントは TUI プラグインを実行しないため、このライフサイクル報告は利用できません。 + +このプラグインは、OpenCode が Herdr のペイン内で動いている間、ライフサイクル状態とセッション識別を報告します。OpenCode がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `opencode --session <id>` でペインを resume できます。プラグインがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。 + +## Kilo Code CLI + +Kilo Code CLI プラグインをインストールします: + +```bash +herdr integration install kilo +``` + +Herdr はプラグインを `~/.config/kilo/plugin/herdr-agent-state.js` に書き込みます。Kilo の設定ディレクトリはあらかじめ存在している必要があります。アンインストールはそのプラグインファイルだけを削除します。 + +このプラグインは、Kilo が Herdr のペイン内で動いている間、ライフサイクル状態とセッション識別を報告します。Kilo がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `kilo --session <id>` でペインを resume できます。プラグインがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。 + +## Hermes Agent + +Hermes Agent プラグインをインストールします: + +```bash +herdr integration install hermes +``` + +Herdr は Hermes ホームディレクトリ内の `plugins/herdr-agent-state/` に書き込み、その `config.yaml` で `herdr-agent-state` を有効にします。`HERMES_HOME` のデフォルトは Unix では `~/.hermes`、Windows では `%LOCALAPPDATA%\hermes` です。Hermes の設定ディレクトリはあらかじめ存在している必要があります。プラグインを読み込ませるため、インストール後に Hermes を再起動してください。アンインストールはプラグインディレクトリを削除し、`plugins.enabled` から `herdr-agent-state` を削除します。 + +このプラグインは、Hermes が Herdr のペイン内で動いている間、resume 可能なセッション id を報告します。Herdr は `working`、`idle`、`blocked` にスクリーンマニフェスト検出を使い、報告されたセッション id で `hermes --resume <id>` としてペインを resume できます。 + +## Qoder CLI + +Qoder CLI フックをインストールします: + +```bash +herdr integration install qodercli +``` + +このフックは、ネイティブ復元のために Qoder CLI のセッション識別を Herdr に報告します。Qoder のフックはすべてのライフサイクル遷移をカバーしていないため、ライフサイクル状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr はデフォルトで `~/.qoder` を使い、`QODER_CONFIG_DIR` が設定されていればそちらを使います。Qoder の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh` を書き込み、`settings.json` に Herdr のフックエントリを追加します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 + +Herdr は保存された Qoder CLI セッションを `qodercli --resume <id>` で resume します。 + +フックがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。 + +## Qwen Code + +Qwen Code フックをインストールします: + +```bash +herdr integration install qwen +``` + +`SessionStart` フックはネイティブ復元用のセッション識別だけを報告します。ライフサイクル状態は引き続き Herdr のスクリーンマニフェスト検出が決定します。 + +Herdr はデフォルトで `~/.qwen` を使い、`QWEN_HOME` が設定されていればそちらを使います。Qwen の設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-session.sh`(Windows では `hooks/herdr-agent-session.ps1`)を書き込み、`settings.json` に Herdr エントリを追加します。アンインストールは一致するエントリと管理対象スクリプトだけを削除します。 + +Herdr は保存された Qwen Code セッションを `qwen --resume <id>` で resume します。 + +## Letta Code + +Letta Code インテグレーションのインストールは実験的な CLI 専用機能です。Settings のインテグレーション一覧や JSON インテグレーション API には含まれません。`herdr integration status` では `letta (experimental)` と表示されます。エージェント検出と `herdr agent start --kind letta` は、このインストール方法の制限とは別にサポートされています。 + +Letta Code フックをインストールします: + +```bash +herdr integration install letta +``` + +静かな `SessionStart` フックは、ネイティブ復元用の会話 ID を報告します。Letta Code の状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr は `~/.letta` を使います。設定ディレクトリはあらかじめ存在している必要があります。インストールは `hooks/herdr-agent-session.sh`(Windows では `hooks/herdr-agent-session.ps1`)を書き込み、`settings.json` に Herdr エントリを追加します。アンインストールは一致するエントリと管理対象スクリプトだけを削除します。 + +Herdr は名前付き会話を `letta --conversation <id>` で resume します。デフォルト会話は `default:<agent-id>` として記録し、`letta --conversation default --agent <agent-id>` で resume します。 + +## Cursor Agent CLI + +Cursor Agent CLI フックをインストールします: + +```bash +herdr integration install cursor +``` + +このフックは、Cursor Agent CLI が Herdr のペイン内で動いている間、Cursor の `sessionStart` フックを通じてセッション識別を報告します。Cursor の状態は Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr はデフォルトで `~/.cursor` を使い、`CURSOR_CONFIG_DIR` が設定されていればそちらを使います。Cursor の設定ディレクトリはあらかじめ存在している必要があります。インストールは `herdr-agent-state.sh`(Windows では `herdr-agent-state.ps1`)を書き込み、`hooks.json` に Herdr の `sessionStart` エントリを追加します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 + +Cursor がセッション開始イベントを発行した後、Herdr は報告されたセッション id を使って `cursor-agent --resume <id>` でペインを resume できます。Herdr がペインを復元するとき、`cursor-agent` コマンドが `PATH` にある必要があります。Herdr は汎用の `agent` コマンドを起動しません。 + +## MastraCode + +MastraCode フックをインストールします: + +```bash +herdr integration install mastracode +``` + +このフックは、MastraCode のライフサイクル状態とスレッド識別を Herdr に報告し、権威ある `idle`、`working`、`blocked` 状態とネイティブ復元を提供します。MastraCode にはスクリーンマニフェストのフォールバックはありません。MastraCode が Herdr ペイン内で動いている間、状態はフックから得られます。 + +Herdr は `~/.mastracode` を使います。インストールは `hooks/herdr-agent-state.sh`(Windows では `hooks/herdr-agent-state.ps1`)を書き込み、`hooks.json` に Herdr のコマンドエントリを追加します。ディレクトリがなければ作成します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 + +Herdr は保存された MastraCode スレッドを `mastracode --thread <id>` で resume します。 + +## Antigravity CLI + +Antigravity CLI フックをインストールします: + +```bash +herdr integration install antigravity-cli +``` + +Herdr はデフォルトで `~/.gemini/config/` を使い、`ANTIGRAVITY_CLI_CONFIG_DIR` が設定されていればそちらを使います。これは Antigravity CLI がグローバルカスタマイズを読み込むディレクトリで、あらかじめ存在している必要があります。インストールは `hooks/herdr-agent-state.sh`(Windows では `herdr-agent-state.ps1`)を書き込み、`hooks.json` に Herdr 所有の `herdr` ブロックを追加します。Antigravity CLI は `hooks.json` をフック名で管理するため、インストールはそのブロックだけを書き換え、ほかの名前付きフックには触れません。アンインストールは `herdr` ブロックとフックスクリプトを削除します。 + +このインテグレーションはセッション専用です。ペインで実行中の会話を報告しますが、エージェント状態は報告しません。そのため、Herdr は Antigravity CLI の画面表示から `working`、`idle`、`blocked` を引き続き判定します。 + +フックは `PreInvocation` で実行されるため、最初のプロンプト送信後に Herdr が会話を認識します。それ以降、Herdr サーバーの再起動後に `agy --conversation <id>` でペインを resume できます。 + +## Grok CLI + +Grok CLI フックをインストールします: + +```bash +herdr integration install grok +``` + +このフックは、Grok CLI が Herdr のペイン内で動いている間、Grok の `SessionStart` フックを通じてセッション識別を報告します。Grok の状態は Herdr のスクリーンマニフェスト検出から得られます。 + +Herdr はデフォルトで `~/.grok` を使い、`GROK_HOME` が設定されていればそちらを使います。Grok の設定ディレクトリはあらかじめ存在している必要があります。Grok はそのディレクトリ内のすべての `hooks/*.json` ファイルを統合するため、インストールは Herdr の `SessionStart` エントリだけを含む `hooks/herdr.json` と `hooks/herdr-agent-state.sh`(Windows では `hooks/herdr-agent-state.ps1`)を書き込み、ほかのフックファイルには触れません。アンインストールは Herdr 所有のこの 2 ファイルだけを削除します。 + +Grok がセッション開始イベントを発行した後、Herdr は報告されたセッション id を使って `grok --resume <id>` でペインを resume できます。 + +## カスタムステータスラベル + +インテグレーションは、ライフサイクル状態を意味的な状態としてのみ報告します。たとえば、ライフサイクル報告に表示フィールドを加えず、エージェントを `working` として報告します。 + +```bash +herdr pane report-agent w1:p1 \ + --source custom:docs \ + --agent docs-bot \ + --state working +``` + +Herdr 管理のインテグレーションと並走するユーザーフックは、`report-agent` ではなくメタデータを使うべきです。メタデータは、インテグレーションの `idle`、`working`、`blocked` やセッション復元の権威を奪わずに表示を変えます。`--agent` と `--applies-to-source` は表示フィールド (`--title`、`--display-agent`、`--state-label`) だけを保護します。トークンパッチは常に適用され、クリアまたは TTL の更新は報告側が管理します。`--display-agent` は表示名を変更します。 + +```bash +herdr pane report-metadata "$HERDR_PANE_ID" \ + --source user:claude-title \ + --agent claude \ + --title "Refactor auth middleware" \ + --display-agent "Claude: auth" \ + --token summary="refactor auth" \ + --state-label working="refactoring auth" \ + --ttl-ms 3600000 +``` + +トークンと状態ラベルは表示専用です。wait、通知、ワークスペースのロールアップは引き続き意味的な状態を使います。 + +## インテグレーション状態のデバッグ + +既知のエージェントを一覧します: + +```bash +herdr agent list +``` + +Herdr に何が見えているか確認する必要があるときはペインを読みます: + +```bash +herdr pane read w1:p1 --source recent --lines 50 +``` + +インテグレーションの状態がおかしく見えるときは、まずエージェントが Herdr 内で動いていること、そして該当のフックまたはプラグインが同じユーザーアカウントにインストールされていることを確認してください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/keyboard.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/keyboard.mdx new file mode 100644 index 00000000..2e547b64 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/keyboard.mdx @@ -0,0 +1,132 @@ +--- +title: キーボード +description: プレフィックスとは何か、最初に覚えるべきバインド、プレフィックスなしで運用する方法。 +--- + +:::tip[tmux や zellij から乗り換え?] +このモデルはすでにご存じのはずです。完全なデフォルトキーマップと設定構文は[キーバインドリファレンス](/ja/docs/configuration/#キーバインド)へどうぞ。 +::: + +Herdr はマウスネイティブです。キーバインドをひとつも覚えなくても、ペイン、タブ、ワークスペース、エージェントをクリックし、分割境界をドラッグし、右クリックメニューを使えます。キーボード操作は任意のレイヤーであり、必須ではありません。 + +## プレフィックスとは何か + +ターミナルマルチプレクサは、ターミナルとその中で動くプログラムの間に位置します。プログラムはすでにほとんどのキーの組み合わせを使っています: `ctrl+c` は中断、`ctrl+r` は履歴検索、エディタは残りのほぼすべてを占有します。もし Herdr がよく使われるキーを直接奪えば、中のプログラムを壊してしまいます。 + +プレフィックスはこれを解決します。プレフィックスキー (デフォルト `ctrl+b`) を押すと、次のキー入力はターミナルではなく Herdr に送られます。`prefix+c` は「`ctrl+b` を押して離し、次に `c` を押す」という意味です。何十ものキーを予約する代わりに、ひとつだけ予約します。 + +いつでも `prefix+?` を押すと、すべての有効なバインドが表示されます。キーバインドヘルプで `/` を押すと、アクションとショートカットを絞り込めます。Backspace でフィルターを編集し、`ctrl+u` でクリアできます。 + +## まずこの 5 つを覚える + +| アクション | キー | +| --- | --- | +| 新しいタブ | `prefix+c` | +| 右 / 下に分割 | `prefix+v` / `prefix+minus` | +| ペイン間の移動 | `prefix+h/j/k/l` | +| ワークスペースナビゲーション | `prefix+w` | +| すべてを動かしたままデタッチ | `prefix+q` | + +これで日常の移動のほとんどをカバーできます。それ以外はマウスのままで大丈夫です。 + +## 残りはタスク別に + +ペイン: + +| アクション | キー | +| --- | --- | +| フォーカス中のペインをズーム | `prefix+z` | +| ペインを閉じる | `prefix+x` | +| ペインを入れ替える | `prefix+shift+h/j/k/l` | +| リサイズモード | `prefix+r` | +| コピーモード | `prefix+[` | + +タブ: + +| アクション | キー | +| --- | --- | +| 次 / 前のタブ | `prefix+n` / `prefix+p` | +| タブ 1–9 にジャンプ | `prefix+1..9` | +| タブの名前を変更 | `prefix+shift+t` | +| タブを閉じる | `prefix+shift+x` | + +ワークスペースとセッション: + +| アクション | キー | +| --- | --- | +| 新しいワークスペース | `prefix+shift+n` | +| ワークスペースの名前を変更 | `prefix+shift+w` | +| ワークスペースを閉じる | `prefix+shift+d` | +| Goto ピッカー | `prefix+g` | +| サイドバーの表示切り替え | `prefix+b` | + +完全なキーマップとバインドの構文は[キーバインドリファレンス](/ja/docs/configuration/#キーバインド)にあります。 + +## テキスト欄の編集 + +Herdr の名前入力、worktree のブランチ名、フィルター、コピーモードの検索欄では、カーソル位置で編集できます。以下は Herdr 自身の入力欄の操作であり、ペイン内のシェルやエージェントの操作は変わりません。 + +| 操作 | キー | +| --- | --- | +| 1 文字移動 | Left / Right、Ctrl+B / Ctrl+F | +| 先頭 / 末尾へ移動 | Home / End、Ctrl+A / Ctrl+E | +| 1 単語移動 | Alt+B / Alt+F | +| 前の文字を削除 | Backspace、Ctrl+H | +| 次の文字を削除 | Delete、Ctrl+D | +| 先頭 / 末尾まで切り取り | Ctrl+U / Ctrl+K | +| 前の単語を切り取り | Ctrl+W、Alt+Backspace、Ctrl+Backspace | +| 次の単語を切り取り | Alt+D | +| 最後に切り取ったテキストを挿入 | Ctrl+Y | + +入力と貼り付けはカーソル位置に挿入されます。移動と削除は Unicode の書記素境界を尊重し、長い値は横にスクロールします。切り取ったテキストは現在の入力欄だけで保持し、システムのクリップボードは使いません。Alt ショートカットにはターミナルの Alt/Meta 報告が必要です。Enter と Esc は引き続きダイアログの確定とキャンセルに使います。 + +## コピーモード + +`prefix+[` を押すと、フォーカス中のペインでコピーモードに入ります。`h/j/k/l`、tmux 形式の `w/b/e`、`{`/`}` で移動します。`/` または `?` で前方または後方のリテラル検索を開始し、`n` または `N` で同じ方向または逆方向に繰り返します。クエリに大文字が含まれる場合だけ大文字と小文字を区別します。`v` または Space で選択を開始し、`y` または Enter でコピーし、`q` または Esc でコピーせずに抜けます。Esc は終了する前に、アクティブな選択または検索を消します。コピーモードはペインのプロセスを停止しません。最下部では出力を追従し、履歴へ移動するとその位置を保ちます。マウスのドラッグ選択なら、コピーモードに入らずにそのままコピーできます。 + +マウスのドラッグ選択と、`v`、Space、`V` で開始した選択は、出力や再描画が続いても維持されます。選択範囲の一部が表示領域の外にある場合も同様です。コピーするのは固定されたスナップショットではなく、その範囲に現在あるテキストです。ペインのサイズ変更や通常画面と代替画面の切り替えでは、引き続き選択が解除されます。検索結果とダブルクリックによる単語選択では、内容変更のチェックが維持されます。 + +## 何でも変更できる + +プレフィックス自体を含め、すべてのバインドは設定可能です: + +```toml +[keys] +prefix = "ctrl+a" +``` + +## プレフィックスなしで運用する + +Herdr のアクションを、プレフィックス不要の直接コードに割り当てることもできます。難しいのはどのコードが安全かを知ることです。ターミナル、シェル、デスクトップ環境がすでにキーボードの大半を占有しているからです。 + +どんなコードでもバインドにできます: `ctrl+j`、`alt+k`、手に馴染むものなら何でも。ただしコードが Herdr に届くまでには 3 つの層を通り抜ける必要があります: オペレーティングシステム、外側のターミナル (Ghostty、iTerm2 などは独自のデフォルトを持ちます)、そしてペイン内で動くプログラムです。`ctrl+j` は Herdr まで問題なく届きますが、シェルやエディタはそれを Enter として扱います。`alt+k` は Linux では空いていますが、macOS ではほとんどのターミナルで特殊文字に合成されます。これらの系統からコードを選ぶ場合は、自分のターミナルと OS のショートカットと衝突しないか確認してください。 + +私たちは Ghostty、iTerm2、Terminal.app、kitty、WezTerm、Alacritty、Warp、Windows Terminal、GNOME Terminal、Konsole のデフォルトキーバインドと、GNOME および KDE のグローバルショートカットを調査しました。ほぼどこでも使われていない修飾キーの系統がひとつあります: `ctrl+alt` です。ターミナルはこれを空けたままにしており、素の `alt` コードを阻む macOS の option キー合成の影響も受けず、モダンなキーボードプロトコルのないターミナルでも送信されます。安全なデフォルトとしてお勧めしますが、選択はあなた次第です。 + +この設定はプレフィックスバインドを維持したまま、直接コードを追加します: + +```toml +[keys] +focus_pane_left = ["prefix+h", "ctrl+alt+h"] +focus_pane_down = ["prefix+j", "ctrl+alt+j"] +focus_pane_up = ["prefix+k", "ctrl+alt+k"] +focus_pane_right = ["prefix+l", "ctrl+alt+l"] +previous_tab = ["prefix+p", "ctrl+alt+["] +next_tab = ["prefix+n", "ctrl+alt+]"] +new_tab = ["prefix+c", "ctrl+alt+c"] +split_vertical = ["prefix+v", "ctrl+alt+d"] +split_horizontal = ["prefix+minus", "ctrl+alt+shift+d"] +zoom = ["prefix+z", "ctrl+alt+z"] +``` + +いくつかの `ctrl+alt` コードは他で使われています。これらは避けてください: + +| コード | 使用しているもの | +| --- | --- | +| `ctrl+alt+arrows` | GNOME のワークスペース切り替え、Ghostty と Konsole のデフォルト | +| `ctrl+alt+t` | Ubuntu と Fedora の「ターミナルを起動」 | +| `ctrl+alt+l` / `ctrl+alt+a` | KDE のロック画面 / アテンションウィンドウ | +| `ctrl+alt+s` / `ctrl+alt+u` | Konsole | +| `ctrl+alt+f1..f12` | Linux の仮想コンソール切り替え | + +直接コードを押しても何も起きない場合、Herdr に届く前にターミナルかデスクトップ環境がそれを消費しています。どちらかを再設定してください: ターミナルの設定でそのコードを解放するか、Herdr で別のコードを選びます。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/marketplace.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/marketplace.mdx new file mode 100644 index 00000000..5afb0675 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/marketplace.mdx @@ -0,0 +1,52 @@ +--- +title: マーケットプレイス +description: GitHub 上のコミュニティ製 Herdr プラグインを探し、自分のプラグインを掲載する方法。 +--- + +Herdr プラグインマーケットプレイスは、コミュニティ製プラグインを探せるインデックスです。 +[herdr.dev/plugins](/plugins/) で閲覧できます。これは公開 GitHub リポジトリの +自動インデックスであり、審査済みカタログではありません。 + +## プラグインを探す + +[マーケットプレイス](/plugins/)には、GitHub トピック `herdr-plugin` が付いており、 +デフォルトブランチに必須メタデータを解析できる `herdr-plugin.toml` が 1 つ以上ある +公開リポジトリが掲載されます。リポジトリとプラグインのメタデータを検索でき、 +リポジトリカードを人気順、最近の活動順、新着順で並べ替えられます。各カードは +ソースリポジトリにリンクし、その中で検出されたすべてのプラグインを表示します。 + +掲載は自動かつ無審査です。掲載されているのはリポジトリが自らトピックを +付けたからであって、Herdr が検証したからではありません。インストールする前に +[信頼に関するガイダンス](/ja/docs/plugins/#信頼とセキュリティ)を確認してください。 + +## プラグインをインストールする + +マーケットプレイスはインストールの上に発見の仕組みを足すものであり、 +インストールを置き換えるものではありません。どのプラグインも GitHub から +直接インストールできます: + +```bash +herdr plugin install owner/repo[/subdir...] +``` + +ルートまたはサブディレクトリに `herdr-plugin.toml` マニフェストを置いた +通常の公開 GitHub リポジトリを公開すれば、このコマンドが機能します。 +マニフェストと作成方法のリファレンスは[プラグイン](/ja/docs/plugins/)を +参照してください。 + +## 自分のプラグインを掲載する + +公開リポジトリに GitHub トピック `herdr-plugin` を追加し、デフォルトブランチに +必須メタデータを解析できる `herdr-plugin.toml` を 1 つ以上置いてください。 +マニフェストはルートにもサブディレクトリにも配置できます。マーケットプレイスは +1 リポジトリにつき 1 枚のカードを使い、有効な各マニフェストを個別にインストール +できるプラグインとして表示します。インデックスは 30 分ごとに自動更新されます。 + +## 掲載に表示される内容 + +各カードには GitHub リポジトリの名前、オーナー、説明、スター数、主要言語、 +最終 push 時刻が表示されます。プラグイン行には各マニフェストの `name` と `version` +が表示され、正確なソースディレクトリにリンクします。インデックスにはパス、`id`、 +`name`、`version`、`platforms`、`min_herdr_version` と、デフォルトブランチの正確な +コミットが記録されます。フォーク、アーカイブ済みリポジトリ、有効なプラグイン +マニフェストがないリポジトリ、不正なメタデータは一覧から除外されます。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/persistence-remote.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/persistence-remote.mdx new file mode 100644 index 00000000..94bffb8b --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/persistence-remote.mdx @@ -0,0 +1,151 @@ +--- +title: 永続化とリモートアクセス +description: Herdr からのデタッチ、あとからの再アタッチ、名前付きセッション、SSH 越しの接続。 +--- + +Herdr はペインをバックグラウンドサーバーで動かし続けます。ターミナルクライアントはデタッチして、あとで再接続できます。 + +ローカル、SSH、`herdr --remote` の各ワークフローについては [Herdr での作業の進め方](/ja/docs/how-to-work/)を参照してください。 + +## デタッチと再アタッチ + +`ctrl+b q` でクライアントをデタッチします。ペインとエージェントは動き続けます。再度 `herdr` を実行すると再アタッチします。セッションとそのペインを停止するには `herdr server stop` を使います。 + +サーバーの完全停止後に Herdr が再起動すると、保存されたセッションの形を復元します。デタッチ、サーバー再起動、画面履歴リプレイ、エージェントネイティブのセッション復元、ライブハンドオフでそれぞれ何が生き残るかは[セッション状態と復元](/ja/docs/session-state/)を参照してください。 + +## 名前付きセッション + +独立した Herdr サーバーが欲しいときは名前付きセッションを使います。 + +```bash +herdr session list +herdr session attach work +herdr session attach side-project +herdr session stop work +herdr session delete side-project +``` + +名前付きセッションは独自のペイン、タブ、ワークスペース、ソケット、ランタイム状態を持ちます。グローバル設定ファイルは共有されます。 + +セッションを削除するときは、大文字と小文字も含めて `herdr session list` に表示される名前を正確に指定してください。大文字と小文字を区別しないファイルシステムでは、`herdr session delete foo` は `Foo` という名前のセッションの削除を拒否します。 + +スクリプトでは `--json` を使ってください: + +```bash +herdr session list --json +herdr session stop work --json +herdr session delete side-project --json +``` + +## 保存済み SSH マシン + +Local と複数の SSH マシンを 1 つのウィンドウで使うには、[マシンへの接続](/ja/docs/connecting-machines/)を参照してください。セットアップ、切り替え、再接続、設定、リモート自動化を説明しています。保存済みマシンを削除してもクライアントが切断されるだけで、リモートセッションは停止しません。 + +## SSH 越しのリモートアタッチ + +[Herdr での作業の進め方](/ja/docs/how-to-work/)でリモート接続の方法を比較しています。サーバーに SSH してそこで `herdr` を実行する tmux スタイルの方法、保存済み SSH マシンで複数の接続先をまとめる方法、ローカルマシンから単独のセッションに接続する方法があります: + +```bash +herdr --remote workbox +herdr --remote ssh://you@server:2222 +``` + +このモードでは、リモートサーバーがペインを動かし、ターミナル内容とセッション状態を SSH 越しに送信します。ローカルの Herdr がサイドバー、メニュー、テーマを含む UI を描画します。クライアントがローカルで動くため、Herdr は画像クリップボードの貼り付けのようなローカルデスクトップ機能をリモートセッションにブリッジできます。画像をリモートの一時ファイルにコピーし、そのパスを貼り付けます。 + +デフォルトでは、`herdr --remote` はそのアタッチにローカルの Herdr キーバインドを使います。リモートサーバーの設定が異なっていても、ローカルの操作感覚を維持できます。ローカルキーバインドを編集したら、UI の `reload config` でデタッチせずに反映できます。リモートサーバーの設定を使いたい場合は `--remote-keybindings server` を使います。ローカルのカスタムコマンドキーバインドは送信されません。それらのコマンドはリモートホスト上で実行されてしまうからです。 + +繰り返し接続する相手は SSH config を使ってください: + +```text +Host workbox + HostName server.example.com + User you + Port 2222 +``` + +その後はこれでアタッチできます: + +```bash +herdr --remote workbox +``` + +リモートアタッチと保存済み SSH マシンは、Linux、macOS、Windows のクライアントから、x86_64 または aarch64 の Linux/macOS ホスト、および x86_64 の Windows ホストへの接続に対応します。両端が安定したエンドポイント世代に対応していれば、バージョンは一致しなくても構いません。Linux/macOS ではリモートの `PATH` 上の互換バイナリを優先し、次に一般的な直接インストール、Homebrew、mise、Nix のパスを確認します。Windows では `PATH` と現在有効な管理対象リリースを確認します。互換バイナリがなければ、対話的な実行はインストールや更新の確認を求め、非対話的な実行はホストを変更せず失敗します。Linux/macOS では `~/.local/bin/herdr` に、Windows ではアプリローカル ConPTY ランタイムを含む完全なパッケージをインストールします。リモートの `PATH` に `~/.local/bin` がない場合は警告します。 + +デフォルトでは、`herdr --remote` はユーザーの SSH config を最初に include し、その後にフォールバックのキープアライブ設定を加えた一時的な SSH config を通してリモートセットアップとブリッジを実行します。既存のユーザーのキープアライブ設定が優先されます。Linux と macOS クライアントは接続再利用用のプライベートなアタッチ単位コントロールソケットも使いますが、Windows OpenSSH では使いません。Herdr が生成する設定やコントロールソケットを使わず通常の `ssh` を使うには `[remote].manage_ssh_config = false` を設定してください。 + +リモート接続は通常の OpenSSH 認証を使います。非対話型シェル、スクリプト、CI、モバイルターミナルなどで鍵のパスフレーズを入力できない場合は、先に ssh-agent へ鍵を追加してください: + +```bash +ssh-add +herdr --remote workbox +``` + +認証に失敗したら、まず `ssh workbox` で通常の SSH 接続を確認してください。 + +バージョンが違うだけでは、実行中のリモートサーバーを置き換えたり再起動したりしません。必要な互換性や独立したバックグラウンド起動に未対応のサーバーは、停止前に確認します。保存済みマシンには画面配信とヘルスチェックの能力も必要です。[マシンへの接続](/ja/docs/connecting-machines/)を参照してください。デフォルトの回答は No で、停止するとペインのプロセスも終了します。この一度限りの更新で、対応サーバーのプロセスを実験的なライブハンドオフで維持したい場合は `--handoff` を渡します: + +```bash +herdr --remote workbox --handoff +``` + +先にサーバーへ SSH してそこで `herdr` を実行した場合、Herdr は完全にサーバー上で動きます。このモードは便利でシンプルですが、通常のターミナルテキスト貼り付けを超えてローカルデスクトップのクリップボードにはアクセスできません。 + +Linux/macOS ホストでは、プラットフォームが一致する直接インストールなら現在のローカルバイナリをコピーできます。Homebrew、mise、Nix のインストール、異なるプラットフォーム、または Windows ホストの場合は、`https://herdr.dev/latest.json` から現在のクライアントバージョンに対応するリリースアセットをダウンロードします。 + +ローカルビルドやカスタムパッケージを使うには、リモートアタッチ前に `HERDR_REMOTE_BINARY` にローカルファイルパスを設定します。Linux/macOS 向けには実行ファイル、Windows 向けには完全な `herdr-windows-x86_64.zip` パッケージを指定してください。 + +```bash +HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox +``` + +## リモートの名前付きセッション + +リモートホスト上の名前付きセッションにアタッチするには、`--remote` と一緒に `--session` を使います: + +```bash +herdr --remote workbox --session agents +``` + +## ダイレクトターミナルアタッチ + +完全な Herdr アタッチはワークスペース UI 全体を開きます。ダイレクトアタッチは、サーバーが所有するターミナルをひとつだけ、現在のターミナルに開きます。 + +ダイレクトターミナルアタッチは Linux と macOS で利用でき、ネイティブ Windows では利用できません。 + +エージェントターゲットでアタッチ: + +```bash +herdr agent attach reviewer +``` + +ターミナル ID でアタッチ: + +```bash +herdr terminal attach term_abc123 +``` + +ダイレクトアタッチは、現在レンダリングされているターミナル状態をストリーミングし、その後ライブの ANSI フレームを流します。入力はそのターミナルに直接送られます。 + +`ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 + +ひとつのターミナルの入力とリサイズを所有できる書き込み可能なダイレクトアタッチクライアントはひとつだけです。既存の所有者を置き換えるには `--takeover` を使います: + +```bash +herdr terminal attach term_abc123 --takeover +``` + +描画済みターミナルデータだけが必要な外部ブリッジには、読み取り専用のオブザーバーを使えます: + +```bash +herdr terminal session observe w1:p1 --cols 120 --rows 40 +``` + +base64 の ANSI データを含む `terminal.frame` を改行区切り JSON で出力し、サーバーがストリームを閉じると `terminal.closed` を出力します。複数のオブザーバーが同じターミナルを監視でき、入力、リサイズ、スクロール、所有権を取得することはありません。 + +対話型ブリッジには書き込み可能なコントローラーを使います: + +```bash +herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 +``` + +同じフレームを出力し、標準入力から改行区切り JSON コマンドを読み取ります。`terminal.input` はテキストまたは base64 データ、`terminal.resize` は表示サイズ、`terminal.scroll` はビューポートのスクロール、`terminal.release` はコントローラーの終了を扱います。入力とリサイズを所有できるコントローラーは 1 つだけです。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/plugins.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/plugins.mdx new file mode 100644 index 00000000..aae2ad41 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/plugins.mdx @@ -0,0 +1,370 @@ +--- +title: プラグイン +description: マニフェストのアクション、イベントフック、ペインを備えたローカル Herdr プラグインを作成します。 +--- + +Herdr プラグインは、共有可能で実行可能なワークフローパッケージです。プラグインは +Bash スクリプト、JavaScript アプリ、Lua スクリプト、Rust バイナリ、その他マシンで +実行できる任意の argv コマンドにできます。Herdr はホスト側の面を担います: +インストール、マニフェスト検証、キーバインド、ターミナルペイン、イベント、 +呼び出しコンテキスト、ソケットアクセスです。プラグインは自分の実装言語、依存関係、 +ファイル、永続状態を担います。 + +プラグインは Herdr を軽量に保つために存在します。コアはターミナルワークスペース、 +ペイン、エージェント、安定した CLI/ソケット API に集中し続けます。プラグインは +その既存の拡張面を、あらゆるワークフローを Herdr 本体に追加することなく、 +誰もが作成・インストール・共有できる再利用可能なワークフローに変えます。 + +プラグインは SDK インテグレーションではありません。`herdr-plugin.toml` +マニフェストと、Herdr が起動できるコマンドを持つディレクトリです。Herdr は +マニフェストを検証し、ランタイムコンテキストを注入し、宣言されたコマンドを起動し、 +ログを記録します。コマンドはさらに作業が必要なときに、CLI またはソケット経由で +Herdr を呼び出します。 + +独立したプラグイン SDK や制限付きコマンドセットはありません。Herdr CLI 全体が +プラグイン API です: [CLI リファレンス](/ja/docs/cli-reference/)のすべてのコマンドを +プラグインから使えます。自分で `herdr ...` として実行できるものは、プラグインも +実行できます。ほとんどのプラグインは、実行中の Herdr バイナリを指す +`HERDR_BIN_PATH` を通じて Herdr を呼び出すべきです。これにより、Unix ソケットと +Windows 名前付きパイプの両方でプラグインの移植性が保たれます。生の JSON リクエストを +自分で送りたいときは[ソケット API](/ja/docs/socket-api/)を使ってください。 + +ランタイムでのアクション登録と、ターミナル以外のネイティブなプラグイン UI は +プラグイン v1 の範囲外です。アクション、イベントフック、ペイン、リンクハンドラーは +すべてマニフェストで宣言します。 + +## 信頼とセキュリティ + +プラグインはあなたのマシンで動く普通のコードです。インストールまたはリンクすると、 +そのビルドコマンドとランタイムコマンドはあなたのユーザーとして、あなたの環境で +実行され、Herdr CLI 全体を呼び出せます — エディタ、シェル、コーディングエージェントに +追加する拡張機能と同じです。この開放性こそが狙いであり、少しの判断力があれば +安全に保てます。 + +信頼できる作者とリポジトリからプラグインをインストールし、新しいプラグインが +何をするのか先に目を通してください: `herdr-plugin.toml` マニフェストと、実行される +スクリプトやバイナリです。`herdr plugin install` は対話的なターミナルでソースと +実行されるコマンドのプレビューを表示するので、確定する前に確認できます。すでに +信頼しているソースには `--yes` を使い、特定のリビジョンが欲しいときは `--ref` で +固定してください。 + +Herdr はマニフェストを検証し、各プラグインの設定と状態を専用ディレクトリに +保ちますが、プラグインの動作をレビューしたりサンドボックス化したりはしません。 +サードパーティのプラグインは Herdr ではなく作者のものです。検証と実行の判断は +あなた自身に委ねられます。 + +## マニフェスト + +マニフェストは Herdr とプラグインの間の契約です。パッケージのメタデータ、対応 +プラットフォーム、任意のビルドコマンド、Herdr が実行できるエントリーポイントを +宣言します。 + +```toml +id = "example.layout" +name = "Layout" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Apply project layouts" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["npm", "ci"] + +[[build]] +command = ["npm", "run", "build"] +platforms = ["linux", "macos"] + +[[startup]] +command = ["node", "dist/restore.js"] + +[[actions]] +id = "apply" +title = "Apply layout" +contexts = ["workspace"] +command = ["node", "dist/apply.js"] + +[[events]] +on = "worktree.created" +command = ["herdr", "workspace", "list"] + +[[panes]] +id = "board" +title = "Project board" +placement = "overlay" +command = ["herdr-board"] + +[[link_handlers]] +id = "github-issue" +title = "Open GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "apply" +``` + +トップレベルの `id`、`name`、`version`、`min_herdr_version` は必須です。 +`min_herdr_version` には、プラグインが使うプラグイン API、イベント名、 +マニフェストフィールドをサポートする最も古い Herdr バージョンを設定してください。 +プラグインの最低バージョンが現在のバイナリより新しい場合、Herdr はリンクや +インストールを拒否します。`description` は任意です。プラグイン id には ASCII の +英字、数字、ドット、コロン、アンダースコア、ハイフンが使えます。 + +アクション id、ペイン id、リンクハンドラー id はプラグイン内部のローカル id です。 +ASCII の英字、数字、コロン、アンダースコア、ハイフンが使えますが、ドットは +使えません。各 id の種類はプラグイン内で一意でなければなりません。グローバルに +一意な名前が必要なとき、Herdr はアクション id を `plugin.id.action` の形に修飾します。 + +プラグインが動作する場所は `platforms = ["linux", "macos", "windows"]` で宣言します。 +ビルドコマンド、スタートアップフック、アクション、イベントフック、ペイン、 +リンクハンドラーも独自の `platforms` を宣言でき、項目レベルの platforms は +トップレベルのリストを上書きします。トップレベルの `platforms` がないローカル +プラグインは警告付きでリンクされます。 + +`command` の値は argv 配列です。Herdr はこれをシェル経由で実行しないため、 +コマンド自身がシェルを起動しない限りシェル展開はありません。言語固有の挙動は +スクリプトやバイナリの側に置いてください。 + +## 最初のプラグイン + +`herdr-plugin.toml` と実行可能なスクリプトまたはプログラムをひとつ含む +ディレクトリから始めます: + +```text +my-plugin/ + herdr-plugin.toml + index.js +``` + +```toml +id = "example.workspace-tools" +name = "Workspace Tools" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Small workspace helpers" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "list-workspaces" +title = "List workspaces" +contexts = ["workspace"] +command = ["node", "index.js"] +``` + +コマンドの中からは `HERDR_BIN_PATH` で Herdr を呼び出します: + +```js +const { spawnSync } = require("node:child_process"); + +const herdr = process.env.HERDR_BIN_PATH ?? "herdr"; +const result = spawnSync(herdr, ["workspace", "list"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}); + +process.stdout.write(result.stdout); +process.stderr.write(result.stderr); +process.exit(result.status ?? 1); +``` + +この例は Node を使っていますが、プラグインに Node は必須ではありません。 +マニフェストは Bash、PowerShell、Python、Rust、Go、Lua、Bun など、ユーザーの +マシンで使える任意のコマンドを起動できます。 + +## インストールとリンク + +サンプルプラグインをインストールします: + +```bash +herdr plugin install ogulcancelik/herdr-plugin-examples/agent-telegram-notify +herdr plugin config-dir examples.agent-telegram-notify +herdr plugin list +herdr plugin action list --plugin examples.agent-telegram-notify +``` + +ローカルでプラグインを作成しているときは、代わりに作業ディレクトリをリンクします: + +```bash +herdr plugin link /path/to/plugin +herdr plugin config-dir example.layout +herdr plugin action list --plugin example.layout +herdr plugin action invoke example.layout.apply +herdr plugin pane open --plugin example.layout --entrypoint board +herdr plugin log list --plugin example.layout +``` + +`plugin install` は `owner/repo/subdir` のような GitHub 省略記法のみを受け付けます。 +`git` でクローンし、対話的なターミナルではプレビューを表示し、サポートされる +ビルドコマンドを実行し、チェックアウトを Herdr 管理のプラグインデータの下に保存して +登録します。非対話的なインストールには `--yes` を使ってください。GitHub 管理の +プラグインを再インストールすると、その管理チェックアウトが置き換えられます。 +インストール済みおよびリンク済みのプラグインと、その有効・無効の状態は現在の +ユーザー全体で共有され、すべての Herdr セッションから利用できます。Herdr サーバーが +動作していないときでも `plugin install` と `plugin link` で登録できます。 +Herdr 0.7.3 の名前付きセッションだけにインストールしたプラグインは、再度 install +または link してください。既存のプラグイン設定と状態はそのまま残ります。 +ローカルにリンクされたプラグインへの上書きインストールは拒否されます。先に +ローカルプラグインを unlink または uninstall してください。`plugin install` と +`plugin link` はプラグインの設定・状態ディレクトリを作成し、 +`plugin config-dir <id>` はセットアップドキュメントやシェルスクリプト向けに +設定ディレクトリを表示します。 + +`plugin uninstall <id-or-source>` はプラグインの登録を解除します。GitHub 管理の +インストールでは管理チェックアウトも削除し、プラグイン id と、install で使うのと +同じ `owner/repo[/subdir...]` 省略記法の両方を受け付けます。`plugin unlink <id>` は +登録解除のみを行いファイルには触れないため、ローカル開発に便利です。v1 に独立した +`plugin update` はありません。管理プラグインを更新するには GitHub から +再インストールしてください。 + +サンプル集のリポジトリは `ogulcancelik/herdr-plugin-examples` です。 +`agent-telegram-notify`、`github-link-preview`、`dev-layout-bootstrap` を含む +独立したサンプルプラグインがサブディレクトリに入っています。これらはコピーする +ためのサンプルであり、メンテナンスされる公式プラグインではありません。 + +## ビルドコマンド + +ビルドコマンドは GitHub からの `plugin install` 中、確認の後、Herdr がプラグインを +登録する前に実行されます。ビルドコマンドが失敗するとインストールは中止され、 +プラグインは登録されません。`plugin link` はビルドコマンドを実行しません。 +ローカルの作者は自分で作業ツリーをビルドします。ビルドコマンドはファイルを生成して +構いませんが、インストールプレビュー後に `herdr-plugin.toml` を変更すると +インストールは中止されます。ビルド失敗時には、プラグイン id、ビルドのインデックス、 +作業ディレクトリ、コマンド、終了ステータスまたは spawn エラー、上限付きの +stdout/stderr が、ツールの出力を解釈せずに表示されます。 + +ビルドコマンドも素の argv コマンドですが、ランタイムのプラグインコンテキストや +Herdr のソケット環境変数は受け取りません。プラグインの作者は `cargo`、`npm`、 +`bun`、`lua` といった必要なシステムツールをドキュメントに書いてください。Herdr は +ビルドの失敗を報告しますが、足りないツールチェーンをインストールすることはありません。 + +## スタートアップフック + +`[[startup]]` コマンドは、Herdr がセッションを復元し、API ソケットの準備が整った後に、 +有効なプラグインごとに一度実行されます。ライブハンドオフで新しいサーバーが引き継いだ +ときにも再実行されますが、クライアントの接続、設定の再読み込み、プラグインの link や +enable では実行されません。Herdr はこれらを非同期で開始し、完了を通常のプラグイン +コマンドログに記録します。スタートアップの失敗でサーバーが停止することはありません。 + +スタートアップフックは一度限りの初期化コマンドであり、監視されるデーモンではありません。 +フックはプラグインが所有する状態を復元し、必要な Herdr API を呼び出して終了してください。 +たとえば宣言型の Agent ビューを `HERDR_PLUGIN_STATE_DIR` に保存し、スタートアップフックで +読み込んで再適用できます。 + +スタートアップフックは通常のランタイムプラグイン環境と +`HERDR_PLUGIN_EVENT=startup` を受け取ります。インストールプレビューには、自動実行される +コードを確認できるよう、すべてのスタートアップコマンドが表示されます。 + +## コマンドと環境 + +ランタイムコマンドは、プラグインディレクトリを作業ディレクトリとして実行されます。 +Herdr は `HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ENV=1`、 +`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、`HERDR_PLUGIN_CONFIG_DIR`、 +`HERDR_PLUGIN_STATE_DIR`、`HERDR_PLUGIN_CONTEXT_JSON`、そして利用可能なら +`HERDR_WORKSPACE_ID`、`HERDR_TAB_ID`、`HERDR_PANE_ID` を注入します。アクション +コマンドは加えて `HERDR_PLUGIN_ACTION_ID` を受け取ります。スタートアップフックと +イベントフックは `HERDR_PLUGIN_EVENT`(スタートアップフックでは `startup`)を受け取り、 +イベントフックはさらに `HERDR_PLUGIN_EVENT_JSON` を、ペインコマンドは +`HERDR_PLUGIN_ENTRYPOINT_ID` を受け取ります。 + +`HERDR_PLUGIN_ROOT` は、インストールまたはリンクされたプラグインディレクトリです。 +GitHub からインストールされたプラグインのルートは管理されたソースチェックアウト +なので、そこにユーザーの認証情報や永続状態を保存しないでください。`.env` ファイルの +ようなユーザーが編集する設定は `HERDR_PLUGIN_CONFIG_DIR` の下に、ローカルの +ランタイム状態は `HERDR_PLUGIN_STATE_DIR` の下に置いてください。Herdr はこれらの +ディレクトリを作成し、レガシーなプラグイン設定の場所が存在すれば +`HERDR_PLUGIN_CONFIG_DIR` に初期内容を移しますが、その中身の検証、同期、削除は +しません。ファイル形式とライフサイクルはプラグインが所有します。 + +`HERDR_PLUGIN_CONTEXT_JSON` には、その呼び出しで利用可能な場合、ワークスペース、 +タブ、フォーカス中のペイン、worktree、エージェント、選択テキスト、クリックされた +URL、リンクハンドラーのフィールドが含まれます。シェルプラグインは、よく使う id は +個別の環境変数から読み、完全な形が必要ならコンテキスト JSON をパースできます。 + +Node、PowerShell、Bash など別のランタイムから移植性を保って Herdr を呼び出す必要が +あるときは `HERDR_BIN_PATH` を使ってください。`HERDR_SOCKET_PATH` の背後にある生の +ソケットトランスポートは OS 固有です: Unix クライアントは Unix ソケットパスに、 +Windows クライアントは名前付きパイプに接続します。`HERDR_BIN_PATH` を通じた CLI +呼び出しなら、そのトランスポートの違いを気にせずに済みます。利用可能なコマンドは +[CLI リファレンス](/ja/docs/cli-reference/)、生のリクエスト形式は +[ソケット API](/ja/docs/socket-api/)を参照してください。 + +## ペイン + +マニフェストのペイン `placement` のデフォルトは `overlay` で、アクティブなペインの +上に一時的なズームオーバーレイを開き、閉じるときに以前のフォーカスとズームを +復元します。`plugin.pane.open` リクエストは、マニフェストの placement を +`overlay`、`popup`、`split`、`tab`、`zoomed` で上書きできます。 + +`placement = "popup"` は、タイルレイアウトを変更せずにセッションモーダルなターミナルポップアップを開きます。 +マニフェストまたは open リクエストで任意の `width` と `height` を指定できます。 +省略するとデフォルトの半分のサイズになり、数値なら外側のターミナルセル数、`"80%"` のような文字列ならターミナル領域に対する割合になります。 +Escape を含むすべてのターミナル入力を受け取り、コマンドが終了するか `popup.close` リクエストが送られると閉じます。 +最小サイズより小さい寸法は最小値に制限されます。 + +ペインを常に一時的にしたい場合は、プラグインペインのエントリーポイントに placement を直接宣言します: + +```toml +[[panes]] +id = "picker" +title = "Picker" +platforms = ["linux", "macos"] +placement = "popup" +width = "80%" +height = 20 +command = ["sh", "picker.sh"] +``` + +split、tab、zoomed、overlay のプラグインペインは、開いた後は通常の Herdr ペインです。プラグインはソケットや CLI を +通じて `pane.move`、`pane.swap`、`pane.resize`、`pane.zoom` といった標準のペイン +API を呼び出せます。ペインがタブやワークスペースをまたいで移動しても、Herdr は +プラグインペインの所有権を元のペインに結び付けたまま維持します。 +ポップアップは Herdr ペインではなく、セッションに 1 つだけ存在できるリソースです。 +ペイン id を持たず、プラグインのフォーカスコンテキストを変更せず、ペインのライフサイクルイベントを発行せず、pane、layout、永続化、エージェント API に参加しません。 +そのプロセスには `HERDR_PANE_ID` が渡されず、背後のタイルペインは `HERDR_PLUGIN_CONTEXT_JSON` から参照できます。 +Settings、コピーモード、または別の Herdr モーダルが開いている間にポップアップを開くと `ui_busy` が返り、起動後の `plugin.pane.open` は `ok` を返します。 + +Windows では、ビルドコマンド、アクションコマンド、イベントコマンドは、素の +コマンドが `PATH` にあれば `npm.cmd`、`bun.cmd`、`pnpm.cmd` のような一般的な +`PATHEXT` shim を解決します。ペインコマンドは Herdr の通常の Windows ペイン +ランチャーを使うため、引き続き有効な Windows の argv コマンドでなければなりません。 + +## キーバインド + +インストール済みプラグインのアクションにキーを割り当てます: + +```toml +[[keys.command]] +key = "prefix+l" +type = "plugin_action" +command = "example.layout.apply" +description = "apply layout" +``` + +## リンクハンドラー + +`[[link_handlers]]` を使うと、マッチしたターミナル URL への修飾キー付きクリックを、 +ブラウザで URL を開く代わりにプラグインのアクションにルーティングできます。 +修飾キー付きクリックの修飾キーは macOS を含むすべてのプラットフォームで Control +です。キャプチャされたターミナルのマウスレポートは、Command/Super を通常のクリックと +区別して伝えないからです。`pattern` はクリックされた URL に対してマッチする Rust の +正規表現で、`action` には同じプラグインが宣言したアクション名を指定します。リンク +ハンドラーのアクションは `HERDR_PLUGIN_CONTEXT_JSON` で +`invocation_source = "link_click"`、`clicked_url`、`link_handler_id` を受け取ります。 +シェルプラグインは `HERDR_PLUGIN_CLICKED_URL` と `HERDR_PLUGIN_LINK_HANDLER_ID` も +読めます。ハンドラーは各プラグイン内でマニフェストの順にチェックされます。 + +## ストレージ + +v1 には Herdr が管理するプラグインストレージ API はありません。永続状態が必要な +プラグインは、自分のファイルやデータベースを持ってください。 + +## マーケットプレイス + +コミュニティ製プラグインは[マーケットプレイス](/plugins/)で探せます。これは +GitHub トピック `herdr-plugin` が付いており、必須メタデータを解析できる +`herdr-plugin.toml` が 1 つ以上ある公開 GitHub リポジトリの自動インデックスです。 +プラグインは普通の GitHub リポジトリのままです: 公開して +`herdr plugin install owner/repo[/subdir]` を共有してください。 + +プラグインを掲載するには、公開リポジトリに GitHub トピック `herdr-plugin` を追加し、 +デフォルトブランチのルートまたはサブディレクトリにマニフェストを置きます。 +1 つのリポジトリカードに複数のプラグインを掲載できます。インデックスは 30 分ごとに +更新されます。発見の仕組みは +[マーケットプレイス](/ja/docs/marketplace/)を参照してください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/quick-start.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/quick-start.mdx new file mode 100644 index 00000000..e5e873ce --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/quick-start.mdx @@ -0,0 +1,69 @@ +--- +title: クイックスタート +description: 最初の Herdr ワークスペースを作成し、永続的なターミナルペインでエージェントを動かします。 +--- + +Herdr をまだインストールしていない場合は[インストール](/ja/docs/install/)を参照してください。その後、任意のプロジェクトディレクトリから Herdr を起動します: + +```bash +herdr +``` + +Herdr はデフォルトのバックグラウンドセッションを起動するか、それにアタッチします。ソケットを管理する必要はありません。デタッチしてもエージェントは動き続けます。 + +## ワークスペースを作成する + +セッションにワークスペースがない場合、Herdr は自動的にひとつ開きます。ワークスペースはタブ、ペイン、エージェントを入れるプロジェクト単位のコンテナです。アクティブなプロジェクトごとに専用のワークスペースを作ってください。そうすることでサイドバーのエージェント状態が読みやすくなります。 + +## マウスを使う + +Herdr はマウスネイティブなので、まずはクリックから始めてください。ペイン、タブ、ワークスペース、エージェントをクリックしてフォーカスします。分割境界をドラッグしてリサイズします。右クリックでコンテキストメニューが開き、ペインの分割やタブの作成ができます。テキストをドラッグ選択するとクリップボードにコピーされます。トークンをダブルクリックすると選択し、2 回目の押下を保持してドラッグすると単語単位で選択を広げられます。どちらもマウスを離すとコピーされます。コピーに Ctrl+C は不要です。 + +ターミナルが修飾キー付きクリックを Herdr に渡す場合、Ctrl+クリックでペイン内のリンクを開けます。OSC 8 ハイパーリンクと `http://` または `https://` の URL に対応し、折り返されたリンクの先頭や末尾が表示範囲外でも開けます。Ctrl を押しながらリンク上にポインターを移動すると、表示中の部分に下線が付きます。ポインターを外す、ペイン内容が変わる、または Ctrl の解放イベントを受け取ると下線は消えます。一部のターミナルでは、修飾キーの変化はマウス移動時にだけ報告されます。macOS では、マウスキャプチャが有効な間は Herdr が処理するペインリンクに Ctrl+クリックを使ってください。Cmd+クリックは Shift+Cmd+クリックや `ui.mouse_capture = false` といったターミナルネイティブのバイパス経路でのみ使えます。 + +`ui.right_click_passthrough_modifier` を設定すると、その修飾キー + 右クリックで、右クリック・ホールド・ドラッグのジェスチャーがマウスレポーティング対応のペインアプリに送られます。 + +## エージェントを動かす + +ペインの中でコーディングエージェントを起動します: + +```bash +claude +``` + +`codex`、`pi`、`opencode` など、他の[対応エージェント](/ja/docs/agents/)でも構いません。Herdr は自動的に検出します。サイドバーには各エージェントが `working`、`blocked`、`done`、`idle` のどれかが表示されます — すべてのワークスペースを横断して表示されるので、どのプロジェクトがあなたを必要としているか常に分かります。 + +## キーボード操作 + +キーボード操作は任意です。マウスですべての操作ができます。`ctrl+b` を押してプレフィックスモードに入り、アクションキーを押します。 + +よく使うアクション: + +| アクション | キー | +| --- | --- | +| 右に分割 | `prefix+v` | +| 下に分割 | `prefix+minus` | +| 新しいタブ | `prefix+c` | +| 次 / 前のタブ | `prefix+n` / `prefix+p` | +| ワークスペースナビゲーション | `prefix+w` | +| 新しいワークスペース | `prefix+shift+n` | +| クライアントをデタッチ | `prefix+q` | + +プレフィックスの考え方が初めてなら、[キーボード](/ja/docs/keyboard/)でプレフィックスとは何か、なぜマルチプレクサがこれを使うのか、プレフィックスなしで運用する方法を説明しています。Herdr 内で `prefix+?` を押すとすべての有効なバインドが表示され、`prefix+[` でコピーモードに入ってキーボードからコピーできます。 + +## デタッチして戻ってくる + +`prefix+q` を押すか、単純にターミナルウィンドウを閉じてください。Herdr サーバーとすべてのエージェントは動き続けます。もう一度 `herdr` を実行すると同じセッションに再アタッチします。 + +セッションを本当に終了してペインを停止するには: + +```bash +herdr server stop +``` + +## 次に読むもの + +- [コンセプト](/ja/docs/concepts/) — ワークスペース、タブ、ペイン、エージェントのモデルを 2 分で。 +- [Herdr での作業の進め方](/ja/docs/how-to-work/) — ローカル、SSH、スマートフォン、`herdr --remote` のワークフロー。 +- [エージェント](/ja/docs/agents/) — 対応エージェント、検出、状態の精度を高めるインテグレーション。 +- [設定](/ja/docs/configuration/) — キーバインド、テーマ、通知、その他すべて。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/session-state.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/session-state.mdx new file mode 100644 index 00000000..006ba439 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/session-state.mdx @@ -0,0 +1,116 @@ +--- +title: セッション状態と復元 +description: Herdr が何をライブで維持し、再起動後に何を復元し、履歴から何をリプレイし、エージェントインテグレーションで何を resume し、アップデート時に何をハンドオフするかを理解します。 +--- + +Herdr には複数の状態管理経路があります。それぞれが異なる問題を解決します。 + +## 何が生き残るか + +| ケース | プロセスは動き続ける | レイアウトは戻る | 直近の画面は戻る | エージェントの会話は再開する | +| --- | --- | --- | --- | --- | +| デタッチと再アタッチ | はい | はい | はい (ライブのターミナルから) | はい (プロセスが止まらないため) | +| サーバー再起動 | いいえ | はい | ペイン画面履歴が有効な場合のみ | エージェントネイティブのセッション復元がある場合のみ | +| `--handoff` なしのアップデート | 互換性のあるサーバーは動き続ける。再起動が必要なサーバーは停止/再起動が必要な場合がある | 再起動後は戻る | ペイン画面履歴が有効な場合のみ | エージェントネイティブのセッション復元がある場合のみ | +| `--handoff` ありのアップデート | 対応する稼働中サーバーではベストエフォート | はい | はい (ハンドオフが成功すればライブのターミナルから) | はい (ハンドオフが成功すればプロセスが動き続けるため) | + +以下のセクションでそれぞれの経路を説明します。 + +## ライブ永続化 + +通常のデタッチでは Herdr サーバーは動き続けます。ペイン、シェル、エージェント、サーバー、テスト、コマンドプロセスはそのサーバー内で動き続けます。 + +`ctrl+b q` でクライアントをデタッチします。あとで再アタッチします: + +```bash +herdr +``` + +元のプロセスが一度も止まらないため、これが最も強力な永続化経路です。 + +## スナップショット復元 + +Herdr サーバーが停止して再起動すると、元のペインのプロセスは失われています。Herdr は保存されたセッションの形を復元します: ワークスペース、タブ、ペイン、cwd、レイアウト、フォーカスです。 + +スナップショット復元は、動作中のシェル、サーバー、テスト、その他任意のプロセスを保存しません。より強力な復元経路が使えないペインは、保存されたディレクトリで新しいシェルとして戻ってきます。 + +`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。正常に読み込めた場合は復旧用コピーを作成しません。 + +復旧用コピーは最新の 3 個を保持し、新しいコピーの安全な保存が完了してから古いコピーを削除します。コピーに失敗すると、自動保存時も終了時も元のファイルを変更せず、失敗をログに記録し、次の保存要求時に再試行します。コピーが自動的に復元されることはありません。復旧するには、対象のサーバーを停止し、復旧用ファイルをそのサーバーの `session.json` にコピーしてから再起動します。復旧用コピーにペイン画面履歴は含まれません。 + +## ペイン画面履歴のリプレイ + +ペイン画面履歴は、サーバーの完全な再起動後に直近のターミナル内容を復元します。復元されるのは Herdr が表示できるものであって、元のプロセスではありません。 + +ペイン出力にはシークレット、トークン、プロンプト、コマンド出力が含まれうるため、これはデフォルトで無効です。設定ファイルで有効にします: + +```toml +[experimental] +pane_history = true +``` + +有効にすると、Herdr は保存したペイン履歴を `session.json` の隣の `session-history.json` に保存します。Herdr の設定/セッションディレクトリはターミナル履歴と同じ感覚で扱ってください。 + +## エージェントネイティブのセッション復元 + +一部のエージェントは自分の会話セッションを resume できます。Herdr は、公式インテグレーションが報告したセッション参照を使って、Herdr サーバーの再起動後に対応エージェントのペインを再起動できます。 + +これはデフォルトで有効です。無効にするには: + +```toml +[session] +resume_agents_on_restore = false +``` + +Herdr が resume するのは、現行の公式 Herdr インテグレーションを通じてネイティブセッション参照を報告したペインだけです。 + +クライアントがアタッチしてターミナルサイズとテーマのコンテキストを提供すると、Herdr は各ペインがフォーカスされるのを待たずに、ワークスペースとタブをまたいで復元対象のエージェントペインを resume します。 + +エージェントネイティブのセッション復元には、次の Herdr インテグレーションバージョン以上が必要です: + +| エージェント | 最低 Herdr インテグレーションバージョン | resume コマンド | +| --- | --- | --- | +| Pi | `2` | `pi --session <path-or-id>` | +| OMP | `3` | `omp --resume=<path-or-id>` | +| Claude Code | `6` | `claude --resume <id>` | +| Codex | `5` | `codex resume <id>` | +| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | +| Grok CLI | `2` | `grok --resume <id>` | +| GitHub Copilot CLI | `2` | `copilot --resume=<id>` | +| Devin CLI | `2` | `devin --resume <id>` | +| Droid | `2` | `droid --resume <id>` | +| Kimi Code CLI | `3` | `kimi --session <id>` | +| Qoder CLI | `2` | `qodercli --resume <id>` | +| Qwen Code | `1` | `qwen --resume <id>` | +| Letta Code | `1` | `letta --conversation <id>`、または `default:<agent-id>` の場合は `letta --conversation default --agent <agent-id>` | +| OpenCode | `5` | `opencode --session <id>` | +| Kilo Code CLI | `1` | `kilo --session <id>` | +| Hermes Agent | `2` | `hermes --resume <id>` | +| MastraCode | `1` | `mastracode --thread <id>` | + +インストール済みインテグレーションのバージョンは `herdr integration status` で確認できます。古いインテグレーションは `herdr integration install <agent>` で再インストールしてください。 + +未対応、欠落、無効、重複、または古くなったセッション参照は、保存されたペインディレクトリで通常のシェルとして復元されます。 + +あるペインにエージェントネイティブのセッション復元が適用される場合、Herdr はそのペインでは保存済みペイン履歴のリプレイではなくエージェントセッションの resume を行います。 + +## ライブハンドオフ + +ライブハンドオフは、稼働中の Herdr サーバーを置き換える必要があるアップデートやリモートアタッチのフローのためのものです。古いサーバーにライブペインを新しいサーバーへ移すよう依頼し、サーバー交換をまたいでペインのプロセスが動き続けられるようにします。 + +これはスナップショット復元、ペイン履歴リプレイ、エージェントネイティブのセッション復元とは異なります。ハンドオフは現在のプロセスを生かし続けようとします。他の経路は、古いサーバーが停止した後に状態を再構築します。 + +ハンドオフが保護するのは、ペインの PTY とプロセス、エージェントの識別情報と永続メタデータ、交換後のサーバーに必要なプラグイン/セッション状態など、サーバーが所有する長寿命のセッション状態です。交換境界をまたぐ一時的な協調状態は保持しません。処理中の CLI/API リクエスト、wait、購読ストリーム、クライアントソケット、ペイン間メッセージは中断される可能性があるため、クライアントは再接続して再試行してください。 + +更新済みの Unix サーバーは、ペインを分割して転送することで 64 個を超えるペインを 1 回のハンドオフで送信できます。送信側サーバーがすでにこの機能を備えている必要があります。古いサーバーからの最初の更新では、引き続き 64 ペインの制限が適用されます。その場合は通常のサーバー再起動が必要になることがあり、ペインのプロセスは終了します。分割転送によって更新のインストール順序や他の互換性要件が変わるわけではありません。 + +ライブハンドオフは実験的機能で、オプトインです: + +```bash +herdr update --handoff +herdr --remote workbox --handoff +``` + +素の `herdr update` は新しいクライアントをインストールし、エンドポイント世代 1 のサーバーを動かしたままにします。素の `herdr --remote workbox` も、バージョンが違っていても互換性のあるリモートサーバーを動かしたままにします。停止が必要なのは世代 1 より前のサーバーを一度だけ更新するときです。対応する実行中サーバーをペインプロセスを失わず明示的に置き換えたい場合は `--handoff` を使います。 + +`herdr update --handoff` は Herdr 自身のアップデーターが管理するインストールにのみ適用されます。Homebrew、mise、Nix のインストールはそれぞれのパッケージマネージャーで更新されるため、そこでは `herdr update` は無効化されており、ライブハンドオフは実行できません。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/socket-api.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/socket-api.mdx new file mode 100644 index 00000000..a556100c --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/socket-api.mdx @@ -0,0 +1,754 @@ +--- +title: ソケット API +description: スクリプト、ツール、コーディングエージェントから実行中の Herdr サーバーを制御します。 +--- + +Herdr は、実行中のセッションを調査・制御する必要があるスクリプトやエージェント向けに、ローカルソケット API を公開しています。 + +ほとんどの自動化は CLI ラッパーから始めるべきです。生のソケット API は、直接のリクエスト/レスポンス制御や長寿命のイベント購読が必要なときだけ使ってください。 + +## インテグレーション層を選ぶ + +| 層 | 用途 | +| --- | --- | +| エージェントスキル | ペイン内からの Herdr の使い方をコーディングエージェントに教える。 | +| CLI ラッパー | シェルスクリプト、シンプルなオーケストレーション、人間によるデバッグ。 | +| 生のソケット API | カスタムツール、プロトコルクライアント、イベント購読者。 | + +これらの層は同じ制御面を共有します。 + +## スキーマ + +インストール済み CLI は、その Herdr バイナリに同梱されたソケットプロトコルスキーマを表示できます: + +```bash +herdr api schema +herdr api schema --json +herdr api schema --output herdr-api.schema.json +``` + +通常の `herdr api schema` は短い概要を表示します。`--json` はツール向けの完全な JSON Schema を表示し、`--output PATH` はそのドキュメントをファイルに書き込みます。スキーマには、生のリクエスト、成功レスポンス、エラーレスポンス、発行イベント、購読イベントが含まれます。 + +## 制御できるもの + +ソケット API では次のことができます: + +- ワークスペースの作成、一覧、フォーカス、名前変更、クローズ +- タブの作成、一覧、フォーカス、名前変更、クローズ +- ペインの一覧、調査、分割、入れ替え、フォーカス、リサイズ、名前変更、読み取り、クローズ、入力送信 +- CLI ヘルパーを通じたエージェントの一覧、調査、読み取り、プロンプト、待機、名前変更、フォーカス、起動、アタッチ +- フックやプラグインからのカスタムエージェント状態の報告 +- イベントの購読と、出力や状態変化の待機 +- 組み込みインテグレーションのインストールとアンインストール +- サーバーの停止と設定のリロード + +## CLI の例 + +ワークスペースを作成する: + +```bash +herdr workspace create --cwd ~/project --label api +``` + +タブを作成する: + +```bash +herdr tab create --label logs +``` + +ペインを分割してコマンドを実行する: + +```bash +herdr pane split w1:p1 --direction right +herdr pane run w1:p2 "npm test" +``` + +ペインを調査して並べ替える: + +```bash +herdr pane layout --current +herdr pane neighbor --direction right --current +herdr pane resize --direction right --amount 0.1 --current +herdr pane swap --direction right --current +herdr pane zoom --on --current +herdr pane split w1:p1 --direction right --ratio 0.333 +``` + +エージェントを待つ: + +```bash +herdr agent wait w1:p1 --until done +``` + +ペインの出力を読む: + +```bash +herdr pane read w1:p2 --source recent --lines 50 +``` + +## 生のメソッド + +生のソケットメソッド名はドット記法を使います: + +| 領域 | メソッド | +| --- | --- | +| サーバー | `ping`、`server.stop`、`server.reload_config`、`server.agent_manifests`、`server.reload_agent_manifests` | +| 通知 | `notification.show` | +| クライアント | `client.window_title.set`、`client.window_title.clear` | +| セッション | `session.snapshot` | +| ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | +| Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | +| タブ | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | +| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | +| ポップアップ | `popup.close` | +| レイアウト | `layout.export`、`layout.apply`、`layout.set_split_ratio` | +| エージェント | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` | +| イベント | `events.subscribe`、`events.wait` | +| インテグレーション | `integration.install`、`integration.uninstall` | +| プラグイン | `plugin.link`、`plugin.list`、`plugin.unlink`、`plugin.enable`、`plugin.disable`、`plugin.action.list`、`plugin.action.invoke`、`plugin.log.list`、`plugin.pane.open`、`plugin.pane.focus`、`plugin.pane.close` | + +`agent.wait` はサーバー所有でイベント駆動です。解決したペイン占有者に固定されるため、置換されたエージェントが待機を満たすことはありません。`agent.prompt` は `until` と `timeout_ms` を持つ省略可能な `wait` オブジェクトを受け付けます。これにより、プロンプト送信と待機開始を 1 つのリクエストで行い、別々の呼び出し間の競合を避けられます。解決したエージェントがすでに `blocked` の場合、`agent.prompt` は入力を送信せず、待機も開始せずに `agent_blocked` を返します。 + +`workspace.move_block` は、順序付きの `workspace_ids` を `before_workspace_id` の前へアトミックに移動します。アンカーを省略するとブロックを末尾へ移動します。id は一意である必要があり、アンカーをブロックに含めることはできません。レスポンスにはサーバーが確定した順序付きワークスペース一覧が含まれます。 + +`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。ブートストラップ中のイベントを取りこぼさないよう、まず別の接続で `events.subscribe` を開いて確認応答を待ち、そのストリームをバッファしながら `session.snapshot` を呼び出します。スナップショットを適用してから、バッファしたイベントを順番に適用し、そのままストリーミングを続けます。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。 + +CLI の `herdr api snapshot` は、クライアントやエージェントが簡単にブートストラップできるよう、現在の `session.snapshot` レスポンスを JSON で出力します。 + +ペイン制御メソッドは `w1:p1` のような公開ペイン id を使います。スキーマ上 `pane_id` が省略可能なメソッドは、省略時にサーバーのアクティブなフォーカス中ペインを使います。`pane.move` は常に送信元の `pane_id` を要求します。 + +`pane.send_keys` と `pane.send_input.keys` は Herdr のキーコンボ文字列を受け付けます: 通常の印字可能キー、`enter` や `esc` のような特殊キー、`ctrl+h`、`control+j`、`alt+x`、`shift+tab` のような修飾キーコード、`f1` のようなファンクションキー、`minus` や `plus` のような名前付き記号です。`prefix+` のバインド文字列は受け付けません。 + +```json +{"id":"req_current","method":"pane.current","params":{"caller_pane_id":"w1:p1"}} +{"id":"req_layout","method":"pane.layout","params":{"pane_id":"w1:p1"}} +{"id":"req_neighbor","method":"pane.neighbor","params":{"pane_id":"w1:p1","direction":"right"}} +{"id":"req_edges","method":"pane.edges","params":{"pane_id":"w1:p1"}} +{"id":"req_focus","method":"pane.focus_direction","params":{"direction":"right"}} +{"id":"req_resize","method":"pane.resize","params":{"pane_id":"w1:p1","direction":"right","amount":0.1}} +{"id":"req_zoom","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"toggle"}} +{"id":"req_split","method":"pane.split","params":{"direction":"right","ratio":0.333,"env":{"HERDR_ROLE":"tests"}}} +{"id":"req_process","method":"pane.process_info","params":{"pane_id":"w1:p1"}} +``` + +`pane.current` は単一の `PaneInfo` を返します。`caller_pane_id` があるときはそのペインを返します。省略時はアクティブなフォーカス中ペインを返します。 + +ターミナルのスクロール情報を取得できる場合、`PaneInfo` には `scroll` が含まれます: + +```json +{ + "offset_from_bottom": 12, + "max_offset_from_bottom": 240, + "viewport_rows": 30 +} +``` + +クライアントは `offset_from_bottom == 0` を最下部にいる状態として扱えます。 + +### ペイングラフィックス + +ペイングラフィックスを使うと、プラグインがペイン上に画像データを配置できます。デフォルトで利用でき、サーバーで `[terminal].kitty_graphics = false` が設定されている場合は、すべてのペイングラフィックスメソッドが `feature_disabled` を返します。この設定の変更にはサーバーの再起動が必要です。`pane.graphics.info` はグラフィックスレイヤーを作成せず、接続中クライアントのセルの幅と高さをピクセル単位で返します。`pane.graphics.set` は `data_base64` の `png`、`rgb`、`rgba` データを受け取り、`pane.graphics.clear` はレイヤーを削除します。 + +```json +{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} +{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} +{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} +``` + +フレームを繰り返し送る場合は、`pane.graphics.stream` で専用ソケットを開きます。Herdr が `ok` を返した後、フレームごとに 1 つの JSON ヘッダーと正確に `data_length` バイトの生データを送ります。ソケットが閉じるまでストリームがそのペインのグラフィックスレイヤーを所有し、同時に set、clear、stream を要求すると `stream_conflict` を返します。 + +```json +{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}} +{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} +``` + +`pane.layout` は、`workspace_id`、`tab_id`、`zoomed`、外側の `area`、`focused_pane_id`、ペインの矩形、分割の矩形/比率を含むタブレイアウトのスナップショットを返します。`pane.neighbor` と `pane.edges` は同じレイアウトスナップショットを含むので、クライアントは非公開のレイアウト状態なしに次の判断を下せます。 + +`pane.process_info` は、ペインのシェル pid、利用可能な場合はフォアグラウンドプロセスグループ id、そしてプラットフォームが公開している場合は pid、名前、argv/cmdline、cwd を含むフォアグラウンドプロセスを返します。 + +`layout.export` は移植可能なタブレイアウトツリーを返します。アクティブなタブをエクスポートするには `tab_id` と `pane_id` の両方を省略し、特定のタブなら `tab_id` を、そのペインを含むタブなら `pane_id` を渡します。 + +```json +{"id":"req_export","method":"layout.export","params":{"tab_id":"w1:t1"}} +``` + +レスポンスには `workspace_id`、`tab_id`、`zoomed`、`focused_pane_id`、`root` が含まれます。`root` は `pane` ノードと `split` ノードの BSP ツリーです。ペインノードは `pane_id`、`label`、`cwd`、argv の `command` を含められます。分割ノードは `direction` (`right` または `down`)、`ratio`、`first`、`second` を使います。 + +`layout.apply` は宣言的なツリーから新しいタブを作成します。`tab_id` が与えられた場合、Herdr は先に置き換え用のタブを作成してから古いタブを閉じます。これは構造、ラベル、cwd、env、任意の argv コマンドを復元しますが、ライブの PTY、スクロールバック、実行中プロセスは保存しません。 + +```json +{ + "id": "req_apply", + "method": "layout.apply", + "params": { + "workspace_id": "wabc", + "tab_label": "dev", + "focus": true, + "root": { + "type": "split", + "direction": "right", + "ratio": 0.65, + "first": { + "type": "pane", + "label": "editor", + "cwd": "/repo" + }, + "second": { + "type": "pane", + "label": "tests", + "cwd": "/repo", + "command": ["sh", "-c", "just test"], + "env": { "HERDR_ROLE": "tests" } + } + } + } +} +``` + +`layout.set_split_ratio` は、タブレイアウト内の既存の分割比率を更新します。レスポンスは、更新済みの移植可能な `layout` を含む `type: "layout_split_ratio_set"` です。 + +```json +{"id":"req_ratio","method":"layout.set_split_ratio","params":{"tab_id":"w1:t1","path":[],"ratio":0.6}} +``` + +プロセスを起動するメソッドは `env` オブジェクトを受け付けます。Herdr はそのキー/値ペアを新しく起動されるプロセスにのみ適用します。Herdr は管理下のペインプロセスに `HERDR_SOCKET_PATH`、`HERDR_ENV=1`、`HERDR_WORKSPACE_ID`、`HERDR_TAB_ID`、`HERDR_PANE_ID` も注入します。呼び出し側が与えた環境変数と衝突した場合、Herdr 管理の変数が権威を持ちます。 + +`pane.swap` は方向指定と明示指定の両方の形式をサポートします: + +```json +{"id":"req_swap_dir","method":"pane.swap","params":{"pane_id":"w1:p1","direction":"right"}} +{"id":"req_swap_explicit","method":"pane.swap","params":{"source_pane_id":"w1:p1","target_pane_id":"w1:p2"}} +``` + +入れ替えは同じタブ内に限られます。分割の形、分割比率、ペイン id、実行中のプロセスを保存します。レスポンスは `type: "pane_swap"` で、`changed`、任意の `reason`、`source_pane_id`、任意の `target_pane_id`、`focused_pane_id`、`layout` を含みます。reason の値は `no_neighbor`、`same_pane`、`not_found`、`cross_tab` です。タブがズームされている場合、入れ替えはズームを維持したまま隠れた全タブレイアウトを変更します。 + +`pane.move` は、実行中のペインを別のタブ、新しいタブ、または新しいワークスペースに移動します: + +```json +{"id":"req_move_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"tab","tab_id":"w1:t2","target_pane_id":"w1:p3","split":"right","ratio":0.5},"focus":true}} +{"id":"req_move_new_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_tab","workspace_id":"w1","label":"logs"},"focus":true}} +{"id":"req_move_new_workspace","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_workspace","label":"logs","tab_label":"main"},"focus":true}} +``` + +既存タブへの移動には `split: "right" | "down"` が必要です。`target_pane_id` は任意で、デフォルトは移動先タブのフォーカス中ペインです。同じタブ内のレイアウト変更は引き続き `pane.swap` です。元のタブへの移動は `reason: "same_tab"` 付きで `changed: false` を返します。ズームされた移動元/移動先タブを含む移動は `reason: "zoomed_tab"` 付きで `changed: false` を返します。 + +レスポンスは `type: "pane_move"` で、`changed`、任意の `reason`、`previous_pane_id`、`previous_workspace_id`、`previous_tab_id`、移動された `pane`、任意の `source_layout`、`target_layout`、任意の作成されたワークスペース/タブのレコード、任意の閉じられたワークスペース/タブの id、`focused_pane_id` を含みます。ワークスペースをまたぐ移動では、内部のペインとターミナルは生きたまま、移動先ワークスペースで新しい公開ペイン id が割り当てられます。購読者は `pane.moved` を購読できます。Herdr は移動されたターミナルプロセスについて偽のペインの close/create イベントを発行しません。 + +`pane.zoom` は、対象ペインのタブのズームをトグル、有効化、無効化します: + +```json +{"id":"req_zoom_toggle","method":"pane.zoom","params":{"pane_id":"w1:p1"}} +{"id":"req_zoom_on","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"on"}} +{"id":"req_zoom_off","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"off"}} +``` + +`pane_id` を省略すると、サーバーのアクティブなフォーカス中ペインが対象になります。レスポンスは `type: "pane_zoom"` で、`changed`、`zoom_changed`、`focus_changed`、任意の `reason`、`pane_id`、`focused_pane_id`、`zoomed`、`layout` を含みます。`changed` は、ズーム状態またはフォーカスのどちらかが変わったときに true です。reason の値は `single_pane`、`already_zoomed`、`already_unzoomed` です。 + +`notification.show` の CLI ラッパーは次のとおりです: + +```bash +herdr notification show "build failed" --body "api workspace" --position top-left --sound request +``` + +設定済みのトースト配信を通じてユーザー通知を表示します: + +```json +{"id":"req_notify","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}} +``` + +`title` は必須で、制御文字と連続する空白を除去した後に可視のテキストが残っていなければなりません。`body` は任意です。Herdr は改行、タブ、キャリッジリターン、連続する空白をスペースにまとめてから、通知テキストを `title` は 80 文字、`body` は 240 文字に切り詰めます。サニタイズ後の `title` が空の場合は `invalid_params` を返します。`position` は任意で、`ui.toast.delivery = "herdr"` のときのみ適用されます。デスクトップの位置は Herdr のフレーム全体を基準とし、省略時は `ui.toast.herdr.position` を使います。terminal、system、off の配信では `position` は無視されます。`sound` は任意で `none`、`done`、`request` のいずれかを指定でき、デフォルトは `none` で、通知が表示されたときにのみ再生されます。 + +レスポンスは何かが表示されたかどうかを報告します: + +```json +{"id":"req_notify","result":{"type":"notification_show","shown":true,"reason":"shown"}} +``` + +reason の候補は `shown`、`disabled`、`rate_limited`、`no_foreground_client`、`busy` です。`disabled` は `ui.toast.delivery = "off"` を意味します。`busy` は既存のアプリ内トーストが置き換えられなかったことを意味します。terminal と system の配信は、現在フォアグラウンドでアタッチしている Herdr クライアントを通じたベストエフォートです。 + +フォアグラウンドクライアントの外側ターミナルウィンドウタイトルを設定またはクリアします: + +```json +{"id":"req_title","method":"client.window_title.set","params":{"title":"herdr api"}} +{"id":"req_title_clear","method":"client.window_title.clear","params":{}} +``` + +`client.window_title.clear` はタイトルを `ui.window_title` に戻します。レスポンスは `type: "client_window_title"` で、`changed` と、`set`、`cleared`、`no_foreground_client` のいずれかの reason を含みます。 + +Worktree メソッドは Git チェックアウトを Herdr ワークスペースとして管理します。`worktree.create` はチェックアウトを作成し、新しい `workspace`、`tab`、`root_pane`、`worktree` のレコードを返します。要求されたブランチがローカルに既存ならそれをチェックアウトし、なければ要求されたベースまたは `HEAD` からブランチを作成します。`worktree.open` は既存のチェックアウトを開くか、すでに開いているワークスペースを返します。`worktree.remove` はリンクされた子ワークスペースに対して `git worktree remove` を実行し、ブランチは決して削除しません。 + +元のワークスペースから worktree を作成する: + +```json +{"id":"req_1","method":"worktree.create","params":{"workspace_id":"w1","branch":"worktree/api","focus":false}} +``` + +既存のチェックアウトを開く: + +```json +{"id":"req_2","method":"worktree.open","params":{"workspace_id":"w1","branch":"worktree/api","focus":true}} +``` + +リンクされたチェックアウトを削除する: + +```json +{"id":"req_3","method":"worktree.remove","params":{"workspace_id":"2","force":false}} +``` + +`worktree.list`、`worktree.create`、`worktree.open` では `workspace_id` と `cwd` は最大どちらか一方だけを使い、両方省略するとアクティブなワークスペースを使います。`worktree.open` では `path` と `branch` のちょうど一方を使います。生のソケットの `cwd` と `path` の値は絶対パスでなければなりません。CLI は相対の `--cwd` と `--path` の値をリクエスト送信前に展開します。ワークスペースが Herdr の worktree グループに属している場合、ワークスペースのレスポンスには任意の `worktree` 出自情報が含まれます。既存のワークスペースが worktree の出自情報を獲得または変更したとき、worktree コマンドは `workspace.updated` を発行することがあります。 + +worktree コマンドはライフサイクルイベントも発行します。`worktree.create` は `workspace.created`、`tab.created`、`pane.created`、`worktree.created` を発行します。`worktree.open` は `worktree.opened` を発行し、新しい Herdr ワークスペースを開いた場合はワークスペース/タブ/ペインの作成イベントも発行します。`worktree.remove` は `worktree.removed` を発行し、リンクされたワークスペースがまだ開いている場合は `workspace.closed` も発行します。 + +リンクされた worktree ワークスペースが開いている親ワークスペースを `workspace.close` で閉じるには、params に `"close_group": true` が必要です。明示的なグループクローズ指定がない場合は `workspace_group_close_required` を返します。明示的なグループクローズは、閉じた各ワークスペースについて `workspace.closed` イベントを 1 件ずつ発行します。 + +## エージェントビューのクエリ + +`agent.view.set` は、組み込みの Agents ビューに一つの一時的な宣言型プロジェクションを +設定します。エージェントの情報や現在の UI コンテキストが変わるたびに再評価されます。 +展開・折りたたみサイドバー、モバイルの Agents リスト、マウスターゲット、番号付き +フォーカス、前後のエージェントへの移動順を制御します。`agent.list`、通知、検出、 +グローバルな要対応件数は変更しません。 + +現在表示中の Space にいるエージェント、または別の場所で対応が必要なエージェントを +表示し、要対応度と直近の状態遷移で並べ替える例です: + +```json +{ + "id": "view_set", + "method": "agent.view.set", + "params": { + "source": "plugin:example.agent-views", + "label": "focus", + "filter": { + "op": "any", + "filters": [ + { + "op": "eq", + "field": "workspace_id", + "value": {"context": "current_workspace_id"} + }, + { + "op": "in", + "field": "status", + "values": ["blocked", "done"] + } + ] + }, + "sort": [ + {"field": "attention", "order": "desc"}, + {"field": "state_change_seq", "order": "desc"} + ] + } +} +``` + +フィルターノードの `op` には `all`、`any`、`not`、`eq`、`in`、`exists` を使います。 +組み込みフィールドは `status`、`workspace_id`、`tab_id`、`pane_id`、`agent`、 +`seen`、`state_change_seq` です。プラグインが報告したペインメタデータを絞り込むには、 +フィールドとして `{"token":"name"}` を使います。値には文字列、真偽値、符号なし整数、 +またはコンテキストオブジェクトを指定できます。コンテキスト値は +`current_workspace_id` と `current_tab_id` で、対応する ID フィールドとの比較にだけ +使えます。保存済みマシンに接続するクライアントでは、選択中サーバーのビューを統合エージェント一覧に適用します。現在のワークスペースとタブのコンテキストには選択中のマシンも含まれるため、別マシンの同じ ID は一致しません。一方、`status` が `blocked` かどうかなど独立したフィルター条件は、接続中のすべてのマシンに一致できます。有効な status は `idle`、`working`、`blocked`、`done`、`unknown` です。 +`done` は idle かつ未確認の状態を表します。 + +ソートフィールドは `workspace_order`、`tab_order`、`pane_order`、`attention`、 +`status`、`agent`、`seen`、`state_change_seq`、または `{"token":"name"}` です。 +ソートは安定しており、指定順に評価され、`asc` または `desc` を受け付けます。値がない +項目は値がある項目の後に残ります。`sort` を省略すると既存の +`ui.agent_panel_sort` ポリシーが有効です。カスタムソートは設定を書き換えず、一時的に +そのポリシーを置き換えます。 + +`source` は所有者を識別します。プラグインは `plugin:<HERDR_PLUGIN_ID>` を使います。 +所有元のプラグインが存在しないか無効な場合、Herdr は設定を拒否します。他の呼び出し元は +独自の非 `plugin:` source を使えます。設定に成功すると以前のビューはアトミックに +置き換えられます。ビューは clear または置換されるか、所有プラグインが disable、unlink、 +uninstall されるか、サーバーが終了するまで有効です。永続的な動作が必要なプラグインは、 +クエリを `HERDR_PLUGIN_STATE_DIR` に保存し、`[[startup]]` フックから再適用してください。 + +無条件で clear するか、指定した source がまだ所有している場合だけ clear できます: + +```json +{"id":"view_clear","method":"agent.view.clear","params":{}} +{"id":"view_clear_owned","method":"agent.view.clear","params":{"source":"plugin:example.agent-views"}} +``` + +source が一致しない場合、アクティブなビューは変わりません。set と clear のレスポンスは +`type: "agent_view"` で、`active`、`source`、任意の `label` を報告します。 + +## プラグイン API + +プラグイン API は、実行可能なワークフローツールのための初期のホスト面です。プラグインは `herdr-plugin.toml` マニフェストを持つパッケージです。マニフェストは、共有可能なアクション、イベントフック、ターミナルペインのエントリーポイント、リンクハンドラーを宣言します。アクションとペインはマニフェスト専用です。ランタイムでのアクション登録とランタイムでの argv ペイン作成は v1 の範囲外です。 + +インストールおよびリンクされたプラグインは再起動をまたいで永続化されます。Herdr は `plugin.link`、`plugin.unlink`、`plugin.enable`、`plugin.disable` の際に、`session.json` の隣に `plugins.json` レジストリファイルを書き込みます。`herdr plugin install` CLI も、Herdr が動作していないときに同じレジストリを書き込み、起動時に自動的に読み込まれます。起動時、Herdr は各マニフェストを元のパスから再読み込みします。ファイルが欠落しているかパースできない場合、エントリは `warnings` フィールド付きで保持され、`plugin.list` がそれを表面化します。 + +イベントフックの `on` の値は、リンク時に既知の Herdr イベント名と照合されます。認識されない名前はエラーではなく、リンクは成功しますが、返されるプラグイン情報に警告が含まれます (例: `"unknown event 'worktree.craeted'"`)。`plugin.link` と `plugin.list` のレスポンスの `warnings` フィールドを確認してください。 + +ローカルのプラグインマニフェストをリンクする: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/path/to/plugin","enabled":true}} +``` + +`plugin.link` は任意の `source` メタデータも受け付けます。CLI は GitHub からインストールするときにこれを使い、`plugin.list` が出自、要求された ref、解決されたコミット、管理チェックアウトのパスを表示できるようにします: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/managed/plugin/herdr-plugin.toml","enabled":true,"source":{"kind":"github","owner":"ogulcancelik","repo":"herdr-plugin-examples","subdir":"worktree-bootstrap","requested_ref":"main","resolved_commit":"abc123","managed_path":"/data/plugins/github/<managed-checkout>","installed_unix_ms":1780000000000}}} +``` + +パスは `herdr-plugin.toml` を含むプラグインディレクトリでも、マニフェストへの直接パスでも構いません。マニフェストの形式は次のとおりです: + +```toml +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Prepare new worktrees" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["bun", "install"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap worktree" +contexts = ["workspace"] +command = ["bun", "run", "bootstrap.ts"] + +[[events]] +on = "worktree.created" +command = ["bun", "run", "bootstrap.ts"] + +[[panes]] +id = "board" +title = "Worktree board" +placement = "overlay" +command = ["bun", "run", "board.ts"] + +[[link_handlers]] +id = "github-issue" +title = "Open GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "bootstrap" +``` + +`min_herdr_version` は必須です。このフィールドが欠落している、無効、または実行中の Herdr バイナリより新しい場合、サーバーはプラグインのリンクを拒否します。 + +プラグインが対応する OS 識別子 (`linux`、`macos`、`windows`) をトップレベルの `platforms` で宣言してください。ローカル開発では `platforms` の省略が許されます — `plugin.link` は成功しますが、レスポンスに警告が含まれます。個々のビルドコマンド、アクション、イベントフック、ペイン、リンクハンドラーは独自の `platforms` を宣言してプラグインレベルのリストを上書きできます。省略時はプラグインから継承します。実効的な platforms が現在の OS を含まないアクションの呼び出しやペインのオープンは `platform_unsupported` エラーを返します。 + +リンク済みプラグインの一覧、有効化、無効化、リンク解除: + +```json +{"id":"req_plugin_list","method":"plugin.list","params":{}} +{"id":"req_plugin_disable","method":"plugin.disable","params":{"plugin_id":"example.worktree-bootstrap"}} +{"id":"req_plugin_enable","method":"plugin.enable","params":{"plugin_id":"example.worktree-bootstrap"}} +{"id":"req_plugin_unlink","method":"plugin.unlink","params":{"plugin_id":"example.worktree-bootstrap"}} +``` + +アクションはリンクされたマニフェストから解決されます。`plugin.action.list` はインストール済みプラグイン全体のすべてのアクションを返します。絞り込むには `plugin_id` を渡してください。 + +```json +{"id":"req_plugin_actions","method":"plugin.action.list","params":{}} +{"id":"req_plugin_actions_filtered","method":"plugin.action.list","params":{"plugin_id":"example.worktree-bootstrap"}} +``` + +`plugin.action.list` は、プラグインレベルの継承を適用した後の各アクションの実効的な `platforms` を返します。 + +修飾 id または素のアクション id でアクションを呼び出す: + +```json +{"id":"req_plugin_invoke","method":"plugin.action.invoke","params":{"action_id":"example.worktree-bootstrap.bootstrap","context":{"invocation_source":"keybinding"}}} +``` + +`plugin.action.invoke` はマニフェストのアクションを解決し、マニフェストのコマンドを起動し、Herdr が構築した呼び出しコンテキストと、開始されたコマンドのログレコードを返します。欠けているコンテキストフィールドは、アクティブなワークスペース、タブ、フォーカス中のペイン、worktree の出自情報、リクエスト id から補完されます。無効化されたプラグインのアクションの呼び出しは `plugin_disabled` エラーを返します。 + +Herdr は `HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ENV=1`、`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、`HERDR_PLUGIN_CONFIG_DIR`、`HERDR_PLUGIN_STATE_DIR`、`HERDR_PLUGIN_CONTEXT_JSON`、そして利用可能な `HERDR_WORKSPACE_ID`、`HERDR_TAB_ID`、`HERDR_PANE_ID` の値を注入します。アクションコマンドは加えて `HERDR_PLUGIN_ACTION_ID` を受け取ります。イベントフックは `HERDR_PLUGIN_EVENT` と `HERDR_PLUGIN_EVENT_JSON` を、ペインコマンドは `HERDR_PLUGIN_ENTRYPOINT_ID` を受け取ります。 + +直近のアクションおよびイベントコマンドのログを一覧する: + +```json +{"id":"req_plugin_logs","method":"plugin.log.list","params":{"plugin_id":"example.worktree-bootstrap","limit":20}} +``` + +イベントフックは、Herdr が `worktree.created` のような一致するイベント名を発行したときに、有効なインストール済みプラグインに対して実行されます。 + +v1 には Herdr が管理するプラグインストレージ API はありません。`HERDR_PLUGIN_CONFIG_DIR` と `HERDR_PLUGIN_STATE_DIR` はパスの発見だけを提供します。ファイル、スキーマ、マイグレーション、クリーンアップはプラグインが所有します。 + +管理されたターミナル UI を開く: + +```json +{"id":"req_plugin_pane","method":"plugin.pane.open","params":{"plugin_id":"example.board","entrypoint":"board","placement":"zoomed","target_pane_id":"w1:p1","env":{"HERDR_ROLE":"board"},"focus":true}} +``` + +`plugin.pane.open` は、インストール済みで有効かつプラットフォーム互換のプラグインを要求し、要求されたマニフェストの `[[panes]]` エントリーポイントを argv ベースのターミナルペインとして起動します。マニフェストのペイン `placement` のデフォルトは `overlay` です。リクエストの `placement` は `overlay`、`popup`、`split`、`tab`、`zoomed` でマニフェストを上書きできます。overlay と popup はアクティブなタイルペインを起動コンテキストとして使います。ポップアップターミナルはセッションモーダルで、タブレイアウトを変更しません。任意の `width` と `height` は、外側のサイズをターミナルセル数または `"80%"` のような割合で指定します。省略した寸法はデフォルトでターミナルの半分になり、小さすぎる値は最小サイズに制限されます。ポップアップはペイン id を持たず、`pane.*` とエージェント API の対象外で、ペインのライフサイクルイベントを発行せず、プラグインのフォーカスコンテキストを背後のタイルペインに維持し、そのプロセスへ `HERDR_PANE_ID` を渡しません。起動は `ok` を返し、`popup.close` はアクティブなポップアップを閉じ、ポップアップがない場合は `popup_not_open` を返します。split と zoomed は既存のペインを対象とし、tab はワークスペースを対象にできます。split、tab、zoomed、overlay は通常の Herdr ペインとして振る舞い、`plugin.pane.focus` と `plugin.pane.close` は引き続きそれらのペインに作用します。 + +## ソケットトランスポート + +Herdr はローカルソケット上の改行区切り JSON を使います。Unix では Unix ドメインソケットです。Windows では名前付きパイプです。 + +1 行につき 1 リクエストを送ります: + +```json +{"id":"req_1","method":"ping","params":{}} +``` + +成功レスポンスは同じ `id` を含みます: + +```json +{"id":"req_1","result":{"type":"pong"}} +``` + +イベント購読は、最初のレスポンスの後も接続を開いたままにします。 + +## ソケットパス + +デフォルトのソケットは Herdr の設定ディレクトリの下にあります。 + +名前付きセッションは別のソケットを持ちます: + +```text +~/.config/herdr/herdr.sock +~/.config/herdr/sessions/<name>/herdr.sock +``` + +解決順: + +1. 明示的な CLI の `--session <name>` +2. `HERDR_SOCKET_PATH` +3. `HERDR_SESSION=<name>` +4. デフォルトセッションのソケット + +`HERDR_SOCKET_PATH` は低レベルな上書きにのみ使ってください。 + +プラグインでは、移植性のある Windows の挙動が必要なときは `HERDR_BIN_PATH` と CLI ラッパーの呼び出しを推奨します。生のソケットクライアントは、プラットフォームネイティブのローカルソケット形式を使う責任を負います。 + +## エージェント状態の報告 + +インテグレーションは `pane.report_agent` でエージェント状態を報告します。 + +```json +{ + "id": "req_1", + "method": "pane.report_agent", + "params": { + "pane_id": "w1:p1", + "source": "custom:docs", + "agent": "docs-bot", + "state": "working", + "message": "building docs" + } +} +``` + +`state` は意味的です。wait、通知、ロールアップに影響します。表示専用の値はメタデータで別に報告します。 + +セッションのみの公式インテグレーションは、`pane.report_agent_session` でネイティブセッション参照を報告します。状態を報告するインテグレーションは、`pane.report_agent` にネイティブセッション参照を含めることもできます。状態に依存しないセッション報告は、wait、通知、ロールアップに影響しません。 + +```json +{ + "id": "req_2", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "herdr:codex", + "agent": "codex", + "agent_session_id": "..." + } +} +``` + +Herdr に保存されたネイティブセッション参照があるとき、`pane.get`、`pane.list`、`agent.get`、`agent.list` は読み取り専用の `agent_session` オブジェクトを公開します: + +```json +{ + "agent_session": { + "source": "herdr:codex", + "agent": "codex", + "kind": "id", + "value": "..." + } +} +``` + +ネイティブセッション参照が保存されていない場合、このフィールドは省略されます。 + +`pane.get`、`pane.list`、`agent.get`、`agent.list` は、ペインの PTY を現在制御しているプロセスの cwd を解決できるときに `foreground_cwd` も公開します。既存の `cwd` フィールドは、ラベル、follow-cwd 挙動、復元されたセッション状態に使われるペイン/ワークスペースの cwd のままです。 + +`PaneInfo` と `AgentInfo` は、任意の `terminal_title` と `terminal_title_stripped` フィールドを公開します。`terminal_title` は、安全性のための正規化を行った最新の OSC 0/2 タイトルです。`terminal_title_stripped` は、先頭にある認識済みのアクティビティまたはスピナーのグリフ 1 つと、それに続く空白を除去します。これらはサーバーが所有する値で、コールドリスタートをまたいで保持されず、メタデータの `title` や意味的なエージェント状態から独立しています。 + +ユーザーフックが Herdr インテグレーションからライフサイクル状態を奪わずに表示をカスタマイズしたいときは `pane.report_metadata` を使ってください。 + +```json +{ + "id": "req_2", + "method": "pane.report_metadata", + "params": { + "pane_id": "w1:p1", + "source": "user:claude-title", + "agent": "claude", + "title": "Refactor auth middleware", + "display_agent": "Claude: auth", + "state_labels": { + "working": "refactoring auth", + "idle": "ready", + "done": "review ready" + }, + "tokens": { + "summary": "refactor auth", + "model": "opus" + }, + "ttl_ms": 3600000 + } +} +``` + +メタデータ報告は表示専用です。有効なメタデータは、ペインのタイトル、表示されるエージェント名、可視の状態ラベル、任意の名前付きトークンを上書きできます。`working`、`blocked`、`idle`、wait、通知、ロールアップは引き続き意味的な状態から得られます。エージェントネイティブのセッション復元は、保存された公式セッション参照から得られます。`agent` は表示フィールドを権威あるエージェントラベルに対して保護する任意のガードで、`applies_to_source` も表示フィールドをアクティブなライフサイクル権威ソースに対して保護します。これらのガードはトークンパッチには適用されません。トークンのクリアと TTL の更新は報告側が管理します。表示名を変えるには `display_agent` を使います。`state_labels` のキーは `idle`、`working`、`blocked`、`done`、`unknown` のいずれかでなければなりません。 + +トークンマップはリソースごとのパッチです。文字列はキーを設定し、JSON の `null` はクリアし、省略したキーは変わりません。最後に受理された更新が優先されます。任意の TTL は、その報告で更新した各トークンキーに個別に適用されます。ペイントークンは pane と agent の get/list 応答で公開され、エージェントサイドバー行では `$name` として描画できます。1 回の報告で指定できるトークンキーは最大 16 個、ペインまたはワークスペースが保持できるキーは最大 32 個です。トークン名は 1〜32 文字の ASCII 英数字、アンダースコア、ハイフンに限られます。 + +ワークスペーストークンも同じ規約を使います: + +```json +{"id":"req_3","method":"workspace.report_metadata","params":{"workspace_id":"w1","source":"user:jj","tokens":{"jj_status":"2 changes","old":null},"ttl_ms":5000}} +``` + +workspace の get/list 応答は結果の `tokens` マップを公開し、スペースサイドバー行では `$jj_status` のような値を描画できます。変更と TTL の失効は、最新のワークスペーススナップショットを含む `workspace.metadata_updated` を発行します。このメタデータイベントは API 購読者に届きますが、プラグインイベントフックは実行しません。 + +表示テキストは保存前に正規化されます。Herdr は前後の空白を取り除き、制御文字を除去し、`title`、`display_agent`、各状態ラベル、トークン値を 80 文字に制限します。正規化後に空になったトークン値は、そのキーをクリアします。 + +`source` と `applies_to_source` はソース識別子です。80 文字以下で、ASCII の英字、数字、コロン、ドット、アンダースコア、ハイフンのみを含められます。 + +短寿命のメタデータには `ttl_ms` を使ってください。`1` から `86400000` ミリ秒の間でなければなりません。置き換え・クリア・ペインまたはワークスペースのクローズまで残るべきメタデータでは省略します。表示フィールドは従来どおりソース単位で失効し、呼び出しで更新した各トークンには個別の期限が設定されます。トークンメタデータはサーバー再起動後に復元されません。 + +フックが順不同で更新を送る可能性がある場合は `seq` を使ってください。同じ `source` について、最後に受理されたシーケンス以下のシーケンス番号を持つ報告は、API には受理されますがペイン状態には無視されます。ペインまたはワークスペースが存続する間、シーケンス付きトークン報告を送れる異なるソースは最大 32 個です。クリアや失効でもそのソース枠は解放されません。 + +## イベント購読 + +長寿命のストリームが必要なときはイベントを購読します: + +```json +{ + "id": "sub_1", + "method": "events.subscribe", + "params": { + "subscriptions": [ + { "type": "pane.agent_status_changed", "pane_id": "w1:p1", "agent_status": "blocked" } + ] + } +} +``` + +最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。ライフサイクルイベントの購読はリクエストが受理された時点から始まり、それ以前に保持されていたイベントは再生しません。 + +ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。`workspace.reordered` は、アトミックに移動した `workspace_ids`、省略可能な `before_workspace_id`、サーバーが確定した順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。 +タブのイベント購読には `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed`、`tab.moved` があります。`tab.moved` は、移動した `tab_id`、`workspace_id`、要求された `insert_index`、そのワークスペースの更新済みの順序付き `tabs` リストを含みます。 +ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。`pane.focused` は、このサーバーに接続した任意のクライアントでの手動ペイン選択も報告します。他のクライアントの表示は移動させず、ペイロードにクライアントの識別情報は含みません。選択済みのペインを再度選んでも発行しません。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。 +レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でブートストラップするクライアントは、同じ `workspace_id` と `tab_id` のキャッシュ済みレイアウトを置き換えてください。 + +worktree のイベント購読には `worktree.created`、`worktree.opened`、`worktree.removed` があります。worktree イベントは Git チェックアウトのライフサイクルを記述します。`worktree.created` は開かれた `workspace` と作成された `worktree` を含みます。`worktree.opened` は対象の `workspace`、開かれた `worktree`、`already_open` を含みます。`worktree.removed` は `workspace_id`、削除された `worktree`、`forced` を含みます。 + +ライフサイクルイベントには `events.subscribe` を使ってください。ワンショットの wait がサポートされる場合、専用の wait ヘルパーが別途ドキュメント化されます。 + +## ペインの読み取り + +プロトコルクライアントを書いているのでなければ、CLI 経由で `pane.read` を使ってください。 + +```bash +herdr pane read w1:p1 --source visible --lines 80 +herdr pane read w1:p1 --source recent --lines 120 +herdr pane read w1:p1 --source recent-unwrapped --lines 120 +herdr pane read w1:p1 --source detection +``` + +`recent-unwrapped` はソフト折り返しを無視するのでログに便利です。 +`detection` は、エージェントのスクリーン検出が使う下部バッファのスナップショットを返します。 + +## 状態の待機 + +エージェントとスクリプトの協調には wait を使います。 + +```bash +herdr agent wait w1:p1 --until done +herdr agent wait w1:p1 --until blocked +``` + +エージェントの wait は、任意のコマンドの完了ではなく意味的な状態を観測します。 + +## レスポンスの形式 + +成功レスポンスは次のようになります: + +```json +{ + "id": "req_1", + "result": { + "type": "pane_info", + "pane": { + "pane_id": "w1:p1", + "terminal_id": "term_abc123", + "workspace_id": "w1", + "tab_id": "w1:t1", + "focused": true, + "agent_status": "working", + "revision": 42 + } + } +} +``` + +`server.agent_manifests` は、ルールをリロードせずに、アクティブなエージェント検出マニフェストのソースとリモート更新の診断情報を返します: + +```json +{ + "id": "req_1", + "result": { + "type": "agent_manifest_status", + "last_check_unix": 1781043522, + "last_result": "checked", + "manifests": [ + { + "agent": "cursor", + "source": "/home/me/.config/herdr/agent-detection/cursor.toml", + "source_kind": "local override", + "active_version": "2026.06.10.1", + "cached_remote_version": "2026.06.10.1", + "local_override_shadowing_remote": true, + "remote_update_result": "current" + } + ] + } +} +``` + +`last_check_unix`、`last_result`、`active_version`、`cached_remote_version`、`remote_update_result`、`remote_update_error`、`remote_last_checked_unix`、`warning` のようなフィールドは、利用できないときは省略されます。`server.reload_agent_manifests` は、メモリ内のルールキャッシュをリロードした後、同じ `manifests` の項目形式を持つ `agent_manifest_reload` を返します。 + +`agent.explain` は、サーバーのアクティブなマニフェストキャッシュを使って、対象ペインの検出スナップショットを実行中のサーバーで評価します: + +```json +{ + "id": "req_2", + "method": "agent.explain", + "params": { "target": "w1:p1" } +} +``` + +レスポンスには `herdr agent explain --json` が出力するのと同じ explain オブジェクトが含まれます: 最終状態、マニフェストのソースとバージョン、マッチしたルール、評価されたルールの証拠、スキップ状態の理由、idle フォールバックの理由、そして完全なライフサイクルフック権威によってスクリーンルールが権威でなくなったときの `screen_detection_skip_reason` です。 + +クライアントには `agent.explain` をサポートする実行中のサーバーが必要です。Herdr のアップグレード後は、このメソッドに頼る前にサーバーを再起動するかライブハンドオフしてください。 + +エラーは次のようになります: + +```json +{ + "id": "req_1", + "error": { + "code": "not_found", + "message": "pane not found" + } +} +``` + +## プロトコルの安定性 + +クライアント描画の Herdr UI は、ローカルサーバーと SSH サーバーに安定したエンドポイント世代を使います。クライアントとサーバーのビルドが一致する必要はありません。接続時にコアのスナップショット、画面、入力、blob コーデックを合意し、サーバーが対応 API メソッドと任意の能力を通知します。保存済み SSH マシンには、選択中のマシンだけがペイン画面を送信するための `surface_interest` と、通信のない接続の障害を検知する `health_check` が必要です。未対応のサーバーは明示的に更新するまで Attention になります。詳しくは[マシンへの接続](/ja/docs/connecting-machines/)を参照してください。メソッドがない場合は、そのサーバー上の該当アクションだけを無効にしてクライアント側通知を表示します。拒否やタイムアウトも接続を終了せず通知します。エンドポイント世代 1 より前のサーバーだけは最後の 1 回の更新が必要です。 + +番号付きバイナリプロトコルは、直接ターミナル接続やライブハンドオフなど、同一インストール内の内部操作に残ります。異なるビルド間でそれらを使う前に `ping` または `herdr status` を確認してください。JSON API クライアントは未知のフィールドを無視し、未対応メソッドを通常のエラーとして扱う必要があります。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/troubleshooting.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/troubleshooting.mdx new file mode 100644 index 00000000..345d8a99 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/troubleshooting.mdx @@ -0,0 +1,120 @@ +--- +title: トラブルシューティング +description: インストール、ターミナル入力、セッション、キーバインド、リモート接続の一般的な問題を診断します。 +--- + +まずバージョンとセッション状態を確認します: + +```bash +herdr -V +herdr status +``` + +OS、外側のターミナル名とバージョン、ローカルまたはリモートのどちらか、tmux を使用しているかも記録してください。 + +## Windows で CJK IME の UI 位置がずれる、またはカーソルがちらつく + +Herdr は、ConPTY ベースのマルチプレクサーによる画面再描画中にネイティブカーソルがちらついたり、ジャンプしたり、古い位置を表示したりすることを避けるため、ネイティブ Windows と WSL ではデフォルトでセル描画カーソルを使います。セル描画カーソルは、韓国語、日本語、中国語 IME の変換中テキストや候補 UI が使用するネイティブカーソルの基準位置を提供できません。 + +IME の変換中テキストや候補ウィンドウが誤った位置に表示される場合は、ネイティブカーソルを有効にしてください: + +```toml +[ui] +host_cursor = "native" +``` + +ネイティブモードでは IME の基準位置が正しくなりますが、出力中にカーソル表示が乱れることがあります。視覚的に安定したカーソルへ戻すには `host_cursor = "drawn"` を設定するか、設定を削除して Windows のデフォルトを使ってください。現在の制限については [Windows サポート](/ja/docs/windows-beta/) を参照してください。 + +## Enter、Tab、Backspace が 2 回入力される + +古いターミナルでは、アプリケーションが Kitty キーボードイベント報告を有効にすると、Enter、Tab、Backspace のリリースをプレスと同じバイトとして送ることがあります。送信後は Herdr から重複したバイトを区別できません。 + +上流の修正を含むバージョンへ外側のターミナルを更新してください: + +| ターミナル | 修正済みの最小バージョン | +| --- | --- | +| kitty | 0.33.0 | +| foot | 1.20.0 | +| Alacritty | 0.15.0 | + +長期サポート版 Linux ディストリビューションの古いターミナルパッケージで特に発生します。確認済みの境界キャプチャと上流参照は [Herdr issue #1116](https://github.com/herdrdev/herdr/issues/1116) を参照してください。現在のターミナルでも発生する場合は、正確なバージョンと Herdr の外でも発生するかを報告してください。 + +## Option+Left または Option+Right を押すと `;3D` または `;3C` が入力される + +ターミナルは一般に、Alt+Left と Alt+Right を標準の修飾付き矢印シーケンス `ESC[1;3D` と `ESC[1;3C` として送信します。macOS では、まず外側のターミナルで Option を Alt として扱うよう設定する必要があります。シェルがこれらのシーケンスを割り当てていない場合、zsh は残りの文字を `;3D` または `;3C` と表示することがあります。これは kitty、Alacritty、その他のターミナルで発生する可能性があります。Herdr と tmux はどちらも元の修飾付き矢印入力を保持します。 + +すべてのターミナルとネストしたシェルで修飾付き矢印を単語移動として使う場合は、zsh のバインドを明示的に追加します: + +```zsh +bindkey $'\e[1;3D' backward-word +bindkey $'\e[1;3C' forward-word +``` + +kitty の外では動作するのに Herdr 内では動作しないことがあります。これは kitty の自動 zsh インテグレーションが、kitty が直接起動したシェルにだけこれらのバインドを追加し、ターミナルマルチプレクサーが起動したシェルには追加しないためです。kitty の[手動シェルインテグレーション手順](https://sw.kovidgoyal.net/kitty/shell-integration/#manual-shell-integration)に従うか、シェルへ届く前に `kitty.conf` でキーを割り当ててください: + +```text +map alt+left send_text all \x1bb +map alt+right send_text all \x1bf +``` + +ターミナルアプリケーションが Alt+Left と Alt+Right を直接使用する場合があるため、Herdr は修飾付き矢印を意図的に書き換えません。調査の詳細は [Herdr issue #1370](https://github.com/herdrdev/herdr/issues/1370) を参照してください。 + +## Herdr を更新したのに実行中のセッションが古い + +バイナリを更新しても、互換性のある実行中サーバーは置き換わりません。Herdr を開き直せば更新済みクライアントを使えます。サーバー側の新機能も必要な場合だけ、`herdr status` で確認してからセッションを停止し、Herdr を再起動します: + +```bash +herdr server stop +herdr +``` + +サーバーを停止するとペインのプロセスも終了します。名前付きセッションでは `herdr session stop <name>` を使います。更新機能、パッケージマネージャー、ライブハンドオフについては[インストール](/ja/docs/install/#update)を参照してください。 + +## macOS の Herdr 内で Keychain を使うツールが失敗する + +Herdr のペインからサーバーの起動コンテキストを確認します: + +```bash +launchctl managername +``` + +自動起動された macOS サーバーでは `Background` は正常で、この表示だけでは Keychain の問題を意味しません。Keychain にアクセスできず、サーバーを SSH やバックグラウンドジョブから起動していた場合は、停止して通常の GUI ターミナルから Herdr を再起動してください: + +```bash +herdr server stop +herdr +``` + +サーバーを停止するとペインのプロセスも終了します。Herdr のペインは長時間動作するサーバーの macOS 起動コンテキストを継承するため、SSH やバックグラウンドジョブから起動されたサーバーは対話型 Keychain サービスにアクセスできないことがあります。詳細は [Herdr issue #966](https://github.com/herdrdev/herdr/issues/966) を参照してください。 + +## macOS のログアウト後に SSH や DNS が失敗する + +自動起動された macOS サーバーはユーザー単位のサービスコンテキストを使用するため、ログアウトしてもペインと macOS サービスの接続が無効になりません。明示的な `herdr server` 起動では、呼び出し元のコンテキストを維持します。 + +古いサーバーはログアウト後も動作し続ける一方、ユーザー情報の検索や DNS が使えなくなることがあります。症状には `No user exists for uid 501`、`Could not get manager name.`、`curl: (6) Could not resolve host` などがあります。再ログインだけでは修復されません。対象セッションを停止し、新しいログインの GUI ターミナルから Herdr を起動してください。実行中のペインプロセスは終了しますが、保存されたセッション配置は復元されます。ライブハンドオフは元のサーバーのサービスコンテキストと既存のペインプロセスを維持します。そのため、古いサーバーにこの修正を適用するにはセッションの完全な再起動が必要です。ハンドオフでは継承したコンテキストを修復できません。 + +新しく起動したサーバーでも問題が起きる場合は、`herdr-server.log` の `could not select persistent user service context` を確認してください。必要な macOS API が利用できない場合、Herdr は継承したコンテキストを維持します。[Herdr issue #4100](https://github.com/herdrdev/herdr/issues/4100) を参照してください。 + +## `herdr` コマンドが見つからない + +ターミナルを再起動して環境を読み込み直し、Herdr のインストール先が `PATH` に含まれていることを確認してください。パッケージマネージャー経由のインストールは、そのパッケージマネージャーから更新して公開する必要があります。[インストール](/ja/docs/install/#verify)を参照してください。 + +## 直接キーバインドが動作しない + +OS または外側のターミナルが Herdr に届く前にキーを処理している可能性があります。そのレイヤーでキーを解放するか、別のバインドを選んでください。既知の競合と安全なデフォルトは[キーボード](/ja/docs/keyboard/#going-prefix-free)を参照してください。 + +## リモートアタッチで認証できない + +まず `ssh <host>` で通常の OpenSSH 接続が動作することを確認してください。非対話シェル、CI、モバイルターミナルでパスフレーズ付きキーを使う場合は、リモートアタッチの前にキーを `ssh-agent` へ読み込んでください。[永続化とリモートアクセス](/ja/docs/persistence-remote/#remote-attach-over-ssh)を参照してください。 + +## 診断ログを探す + +Herdr のログはデフォルトで `~/.config/herdr/` にあります: + +```text +herdr.log +herdr-client.log +herdr-server.log +``` + +詳細なログには `HERDR_LOG=herdr=debug` を設定します。問題を報告するときは現在のログとローテーション済みログを含めてください。[設定](/ja/docs/configuration/#logs)を参照してください。 diff --git a/docs/versions/0.9.1/website/src/content/docs/ja/windows-beta.mdx b/docs/versions/0.9.1/website/src/content/docs/ja/windows-beta.mdx new file mode 100644 index 00000000..db708133 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/ja/windows-beta.mdx @@ -0,0 +1,147 @@ +--- +title: Windows サポート +description: ネイティブ Windows 対応、サポートされるワークフロー、既知の制限。 +--- + +ネイティブ Windows 対応は正式に提供されています。 + +Windows 上の Herdr は、Herdr が本来前提としていた Unix の PTY モデルではなく、ConPTY と Windows のプロセス/ランタイム挙動を使います。主要なワークフローの多くはサポートされていますが、一部の機能は Linux/macOS と異なるか、プラットフォームに依存します。残っている差を埋める間、Windows 固有の修正がより多く提供されることがあります。 + +Windows に Herdr をネイティブインストールするには PowerShell を使います: + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex" +``` + +エンドポイントセキュリティによってこのファイルレス PowerShell コマンドがブロックされる場合は、コマンドプロンプトを開いて次を実行します: + +```cmd +curl.exe -fsSLo install.cmd https://herdr.dev/install.cmd && install.cmd && del install.cmd +``` + +Windows ビルドは安定版とプレビューの両方のアップデートチャンネルで提供されます。新規インストールはデフォルトで安定版を使い、通常の利用には安定版を推奨します。既存のプレビューインストールは、`herdr channel set stable` を実行するまでプレビューに残ります。古いプレビュービルドでこのコマンドが拒否される場合は、プレビューのまま一度 `herdr update` を実行してから再試行してください。プレビューでは `master` からの新しく検証の少ない修正を受け取れますが、リグレッションが起きる可能性があります。そのトレードオフを望む場合に限り、`herdr channel set preview` でオプトインしてください。 + +インストーラーはリリースを `%USERPROFILE%\.herdr\packages\standalone\releases` に保存し、有効なバージョン付きリリースディレクトリを PATH に追加し、`%LOCALAPPDATA%\Programs\Herdr\bin` を安定した互換エイリアスとして維持します。また、実行中のプロセスがアップデートを妨げないよう少数の古いリリースを保持します。 + +内部テスト向けに、`HERDR_MANIFEST_URL` でインストーラーを Herdr の安定版/プレビューマニフェストではなくカスタムマニフェストに向けられます。カスタムプレビューマニフェストでは `HERDR_CHANNEL=preview` も設定してください。 + +## Windows でサポート + +| 機能 | 状況 | +| --- | --- | +| ローカル永続セッション | サポート済み | +| ConPTY によるネイティブペイン | サポート済み | +| Windows Terminal / PowerShell アプリからのアタッチ | サポート済み | +| Linux/macOS/Windows ホストへの `herdr --remote` と保存済み SSH マシン | サポート済み。対話的なアタッチとマシン追加では確認後に Windows パッケージをインストール・更新できます。バックグラウンド再接続ではインストール済みパッケージだけを探します | +| リモートクリップボード画像と画像ファイルのドロップ | サポート済み | +| `cmd.exe` ペイン | サポート済み | +| ネイティブのキーボード/マウス入力 | サポート済み | +| 起動時 cwd とワークスペースラベル | サポート済み | +| ペイン起動時の cwd | サポート済み | +| エージェントコマンドの発見 | サポート済み | +| 対応済みエージェントのセルフレポートインテグレーション | サポート済み | +| エージェントのプロセスツリー検出 | サポート済み | +| 既知の cwd からの Git/worktree 検出 | サポート済み | +| システム通知と MP3 サウンド | サポート済み | +| プラグイン | プレビュー | +| ペイン画面履歴 | サポート済み | +| ネストされた起動のオーバーライド | サポート済み | + +ローカル永続セッションは、クライアントをデタッチした後やターミナルウィンドウを閉じた後も動作し続けます。Windows OpenSSH から起動したサーバーとペインプロセスもログアウト後に存続します。再接続するには `herdr` をもう一度実行してください。 + +Windows のエージェントプロセス検出は、ペインのシェルの子孫プロセスをスキャンし、npm/Node や Git Bash のプロセスチェーンを含む直接のエージェントと一般的なコマンドラッパーを認識します。Git Bash から起動したエージェントはエミュレートされた `exec` 境界を越えて追跡されますが、Unix のフォアグラウンドプロセスグループ検出と同じものではありません。 + +Windows でインテグレーションをインストールできるのは、現在 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、OpenCode、Kilo Code CLI、Droid、Kimi Code CLI、Qoder CLI、Antigravity CLI です。利用可能な対象は Unix より少なく、インストール形式が Windows で未対応のインテグレーションは表示されないか拒否されます。 + +プラグインは、プレビューとしてマニフェストのプラットフォームに `windows` をサポートします。GitHub インストール、ローカルリンク、ビルドコマンド、アクション、イベント、プラグインペインは Windows ではベストエフォートです。コマンドは argv 形式で Windows 互換である必要があります。`npm`、`bun`、`node` のような Node パッケージの shim は `PATH` にあれば動作するはずですが、`sh` や Bash を使う Unix 専用の例には Windows 向けの代替が必要です。プラットフォームフィルターは、未サポートのビルドコマンドをスキップし、未サポートのアクションやペインには `platform_unsupported` を返します。 + +## 部分的なサポート + +| 機能 | 状況 | +| --- | --- | +| シェルで `cd` した後のライブ cwd | 部分的 | +| シェルインテグレーション/OSC7 によるライブ cwd | サポート済み | +| ローカルペインのエージェントへのクリップボード画像貼り付け | ターミナル/エージェント依存 | +| CJK IME の変換候補位置 | 部分的 | +| プレフィックス入力ソース切り替え (韓国語 IME) | 部分的 | +| Kitty graphics のレンダリング | ターミナル依存 | +| ホストカーソル描画 | 部分的 | + +Herdr はペインを正しいディレクトリで起動でき、Herdr を起動したディレクトリから最初のワークスペースを作成できます。起動後の PowerShell のディレクトリ変更は別問題です: Herdr が調べられるプロセスのフィールドは、その後の論理的な `cd` の変化を確実には追跡しません。ライブ cwd の報告には Herdr インテグレーションかプロンプトのシェルインテグレーションを使ってください。 + +`herdr --remote` では、設定されたリモート画像貼り付けキーが Windows のクリップボード画像を読み取り、リモートホストへ転送します。Windows Terminal にローカル画像ファイルをひとつドロップした場合も、そのファイルを転送してリモート側のパスを貼り付けます。 + +プレフィックス入力ソース切り替えは、韓国語 IME 向けのオプトイン実験機能として利用できます。プレフィックスコマンドが有効な間はハングル入力を英語に切り替え、終了後に以前のモードへ戻します: + +```toml +[experimental] +switch_ascii_input_source_in_prefix = true +``` + +その他の Windows IME はまだこのオプションに対応していません。[設定](/ja/docs/configuration/#プレフィックス入力ソースの切り替え)を参照してください。 + +Windows 上の一部のエージェントは `ctrl+v` を受け取り、クリップボード画像を直接読み取れます。Herdr 自身のクリップボード画像リーダーはローカルのネイティブ Windows ペインには配線されていないため、エージェント自身による画像貼り付けはターミナルとエージェントに依存します。`alt+v` などエージェントの画像貼り付けショートカットによって、Herdr が管理するローカルクリップボードブリッジが追加されるわけではありません。リモートクリップボード画像ブリッジは、Linux/macOS/Windows ホストへの `herdr --remote` で別途サポートされます。 + +Kitty graphics はデフォルトで有効で、外側のターミナルに依存します。Herdr は他のプラットフォームと同様に Windows でも Kitty graphics protocol の出力を送ります。この経路は Windows の WezTerm から WSL 内の Herdr をホストする構成で動作確認されていますが、ネイティブ Windows のターミナルと ConPTY の組み合わせはすべて検証済みではありません。Windows Terminal は Herdr が使う Kitty graphics 経路を提供しません。外側のターミナルがグラフィックス出力を正しく扱えない場合は、`[terminal].kitty_graphics = false` に設定してください。 + +## 既知の注意点 + +### カーソル描画 + +Herdr はネイティブ Windows のペインに ConPTY を使用します。現在の Windows のターミナルカーソル処理では、マルチプレクサーが画面を再描画している途中のカーソル位置が表示されることがあります。そのため、出力中にネイティブカーソルがちらついたり、ジャンプしたり、古い位置に一瞬残ったりします。この挙動は他のネイティブ Windows ターミナルマルチプレクサーや、VT カーソル位置を直接連続更新するテストでも再現するため、ネイティブカーソルの挙動を維持したまま Herdr 側で完全に解消することはできません。 + +視覚的な安定性を優先するため、デフォルトの `host_cursor = "auto"` はネイティブ Windows と WSL で Herdr のカーソルを端末セルの内容として描画します。それ以外の Linux と macOS クライアントでは、引き続きネイティブの端末カーソルを使います。Windows の描画カーソルは安定していて点滅しませんが、外側ターミナルのネイティブな点滅、形状、カーソル色は反映されません。 + +描画カーソルは、Windows が IME の変換中テキストや候補ウィンドウの位置決めに使う実際のカーソルではありません。そのため、韓国語、日本語、中国語 IME の UI が誤った位置に表示されることがあります。この問題が発生する場合は、外側ターミナルのカーソルを有効にしてください: + +```toml +[ui] +host_cursor = "native" +``` + +ネイティブモードでは IME の基準位置が正しくなりますが、出力中にカーソルのちらつき、ジャンプ、古い位置の残像が再び発生することがあります。これは現在の Windows 対応における互換性上のトレードオフです。 + +### キーボードとマウス + +Windows のターミナルは、修飾キー付きのキーをすべて同じ形で報告するわけではありません。Herdr は Windows Terminal と Windows 上の Alacritty でマウス報告と `ctrl+j` を保持します。ネイティブ Windows の入力経路では、物理キーの押下、リピート、解放、単独の Escape、`shift+enter` もデフォルトの ConPTY ペインまで保持されます。ただし修飾キーは、外側のターミナルが区別可能なキーイベントとして報告する必要があります。`shift+enter` が通常の Enter として報告された場合、Herdr が転送できるのも通常の Enter だけです。 + +古い Windows 10 ビルドのシステム ConPTY は、Kimi や Pi などのエージェントが使用する Kitty キーボードプロトコルのシーケンスを失うため、Windows パッケージには Microsoft の最新のアプリローカル ConPTY ランタイムが含まれます。バンドル版ランタイムとの互換性問題を診断する場合に限り、Herdr を起動する前に `HERDR_WINDOWS_CONPTY=system` を設定してください。 + +## コピーと貼り付け + +Herdr のペインテキストコピーは Windows で動作します。ペイン内でテキストをドラッグ選択すると Herdr 経由でコピーされます。 + +テキストの貼り付けには Windows Terminal で `ctrl+shift+v` を使ってください。複数行テキストの貼り付けはブラケット付きなので、シェルやエージェントのプロンプトは各行を個別に送信せず、ひとつの貼り付けとして受け取ります。`shift` を押しながら右クリックすると、クリックを Herdr に送らずに外側ターミナルの貼り付けアクションを使えます。 + +## Windows で未サポート + +| 機能 | 状況 | +| --- | --- | +| ダイレクトターミナルアタッチ (`herdr terminal attach`) | 未サポート | +| ライブサーバーハンドオフ | 未サポート | +| Unix ファイルディスクリプタのハンドオフ | 未サポート | +| Unix フォアグラウンドプロセスグループ | 未サポート | +| ローカルのネイティブペインでの Herdr クリップボード画像ブリッジ | 未サポート | +| 署名済みバイナリ / SmartScreen 回避 | 未サポート | + +Windows Terminal からは、Linux や macOS と同じリモートコマンドを使います: + +```powershell +herdr --remote workbox +``` + +接続先ホストは Linux、macOS、Windows に対応します。Windows ホストでは `PATH` または現在有効な管理対象リリースから互換パッケージを再利用します。対話的なアタッチと保存済みマシンのセットアップは、必要に応じて完全なパッケージをインストール・更新する前に確認します。バックグラウンド再接続ではインストール済みパッケージだけを探し、インストールや更新の確認は行いません。Herdr はインストール済みの Windows OpenSSH クライアントと SSH 設定を使います。Windows OpenSSH では Herdr の Unix コントロールソケットによる接続再利用を使わないため、セットアップ中の繰り返しプロンプトを避けるには Windows の `ssh-agent` による鍵認証を推奨します。 + +Windows のアップデートは Windows インストーラー経由で行われ、有効なバージョン付きリリースパスを更新します。新しいターミナルまたは再接続した SSH セッションはそのパスを受け取るため、そこで Herdr を起動すると更新済みクライアントを使えます。互換性のある実行中サーバーはペインを動かしたままにします。新しいリリースのサーバー側変更が必要になったときだけ、後からサーバーを再起動してください。ライブハンドオフは Unix 専用です。 + +## Windows の問題を報告する + +以下を含めてください: + +- Herdr のバージョン。 +- Windows のバージョン。 +- ターミナルアプリ。 +- シェル (PowerShell、cmd など)。 +- 名前付き `HERDR_SESSION` を使っていたかどうか。 +- 関連する Herdr のログ。 +- 正確な再現手順。 diff --git a/docs/versions/0.9.1/website/src/content/docs/keyboard.mdx b/docs/versions/0.9.1/website/src/content/docs/keyboard.mdx new file mode 100644 index 00000000..f1e1394c --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/keyboard.mdx @@ -0,0 +1,132 @@ +--- +title: Keyboard +description: What the prefix is, which bindings to learn first, and how to go prefix-free. +--- + +:::tip[Coming from tmux or zellij?] +You already know this model. Jump to the [keybinding reference](/docs/configuration/#keybindings) for the full default keymap and config syntax. +::: + +Herdr is mouse-native. You can click panes, tabs, workspaces, and agents, drag split borders, and use right-click menus without learning a single keybinding. Keyboard control is optional. + +## What the prefix is + +A terminal multiplexer sits between your terminal and the programs running inside it. Those programs already use most key combinations: `ctrl+c` interrupts, `ctrl+r` searches history, editors claim nearly everything else. If Herdr grabbed common keys directly, it would break the programs inside it. + +The prefix solves this. Press the prefix key, default `ctrl+b`, and the next keypress goes to Herdr instead of your terminal. `prefix+c` means: press `ctrl+b`, release, then press `c`. Herdr reserves one key instead of dozens. + +Press `prefix+?` at any time to see every active binding. Press `/` in the keybind help to filter actions and shortcuts; use Backspace to edit the filter or `ctrl+u` to clear it. + +## Learn these five first + +| Action | Key | +| --- | --- | +| New tab | `prefix+c` | +| Split right / down | `prefix+v` / `prefix+minus` | +| Move between panes | `prefix+h/j/k/l` | +| Workspace navigation | `prefix+w` | +| Detach, leave everything running | `prefix+q` | + +These cover most daily movement. Everything else can stay on the mouse. + +## The rest, by task + +Panes: + +| Action | Key | +| --- | --- | +| Zoom the focused pane | `prefix+z` | +| Close pane | `prefix+x` | +| Swap panes | `prefix+shift+h/j/k/l` | +| Resize mode | `prefix+r` | +| Copy mode | `prefix+[` | + +Tabs: + +| Action | Key | +| --- | --- | +| Next / previous tab | `prefix+n` / `prefix+p` | +| Jump to tab 1–9 | `prefix+1..9` | +| Rename tab | `prefix+shift+t` | +| Close tab | `prefix+shift+x` | + +Workspaces and session: + +| Action | Key | +| --- | --- | +| New workspace | `prefix+shift+n` | +| Rename workspace | `prefix+shift+w` | +| Close workspace | `prefix+shift+d` | +| Goto picker | `prefix+g` | +| Toggle sidebar | `prefix+b` | + +The full keymap and the binding syntax live in the [keybinding reference](/docs/configuration/#keybindings). + +## Editing text fields + +Herdr's naming dialogs, worktree branch fields, filters, and copy-mode search support editing at the cursor. These shortcuts apply to Herdr-owned fields, not to the shell or agent inside a pane. + +| Action | Keys | +| --- | --- | +| Move one character | Left / Right, Ctrl+B / Ctrl+F | +| Move to the beginning / end | Home / End, Ctrl+A / Ctrl+E | +| Move one word | Alt+B / Alt+F | +| Delete the previous character | Backspace, Ctrl+H | +| Delete the next character | Delete, Ctrl+D | +| Cut to the beginning / end | Ctrl+U / Ctrl+K | +| Cut the previous word | Ctrl+W, Alt+Backspace, Ctrl+Backspace | +| Cut the next word | Alt+D | +| Insert the last cut text | Ctrl+Y | + +Typing and paste insert at the cursor. Movement and deletion respect Unicode grapheme boundaries, and long values scroll horizontally. Cut text stays within the current field; it does not use the system clipboard. Alt shortcuts require the terminal to report Alt/Meta. Enter and Esc retain the dialog's confirm and cancel behavior. + +## Copy mode + +Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, tmux-style `w/b/e` and big-word `W/B/E`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move. Press `/` or `?` for forward or backward literal search, then `n` or `N` to repeat in the same or opposite direction. Search is case-insensitive unless the query contains an uppercase letter. Use `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. Esc clears an active selection or search before exiting. Copy mode does not pause the pane process: output remains live, follows at the bottom, and stays pinned when you navigate into history. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. + +Mouse drag selections and selections started with `v`, Space, or `V` remain active during output and redraws, including when part of the selected range is outside the viewport. Copy reads the current text in that range, not a frozen snapshot. Resizing the pane or switching between its normal and alternate screens still clears the selection. Search results and double-click word selections retain their content-change checks. + +## Change anything + +Every binding is configurable, including the prefix itself: + +```toml +[keys] +prefix = "ctrl+a" +``` + +## Going prefix-free + +You can bind Herdr actions to direct chords that need no prefix at all. The hard part is knowing which chords are safe, because terminals, shells, and desktop environments already own most of the keyboard. + +Any chord works as a binding: `ctrl+j`, `alt+k`, whatever fits your hands. But a chord has to survive three layers before Herdr sees it: your operating system, your outer terminal (Ghostty, iTerm2, and others ship their own defaults), and the programs running inside the pane. `ctrl+j` reaches Herdr fine, but shells and editors treat it as enter. `alt+k` is free on Linux, but macOS composes it into a special character in most terminals. If you pick chords from these families, double-check them against your own terminal and OS shortcuts. + +We mapped the default keybindings of Ghostty, iTerm2, Terminal.app, kitty, WezTerm, Alacritty, Warp, Windows Terminal, GNOME Terminal, and Konsole, plus the global shortcuts of GNOME and KDE. One modifier family is almost untouched everywhere: `ctrl+alt`. Terminals leave it free, it is not affected by the macOS option-key composing behavior that blocks plain `alt` chords, and it transmits even in terminals without a modern keyboard protocol. It is a safe default, but the choice is yours. + +This setup keeps the prefix bindings working and adds direct chords on top: + +```toml +[keys] +focus_pane_left = ["prefix+h", "ctrl+alt+h"] +focus_pane_down = ["prefix+j", "ctrl+alt+j"] +focus_pane_up = ["prefix+k", "ctrl+alt+k"] +focus_pane_right = ["prefix+l", "ctrl+alt+l"] +previous_tab = ["prefix+p", "ctrl+alt+["] +next_tab = ["prefix+n", "ctrl+alt+]"] +new_tab = ["prefix+c", "ctrl+alt+c"] +split_vertical = ["prefix+v", "ctrl+alt+d"] +split_horizontal = ["prefix+minus", "ctrl+alt+shift+d"] +zoom = ["prefix+z", "ctrl+alt+z"] +``` + +A few `ctrl+alt` chords are taken elsewhere. Avoid these: + +| Chord | Owned by | +| --- | --- | +| `ctrl+alt+arrows` | GNOME workspace switching, Ghostty and Konsole defaults | +| `ctrl+alt+t` | "Launch terminal" on Ubuntu and Fedora | +| `ctrl+alt+l` / `ctrl+alt+a` | KDE lock screen / attention window | +| `ctrl+alt+s` / `ctrl+alt+u` | Konsole | +| `ctrl+alt+f1..f12` | Linux virtual console switching | + +If a direct chord does nothing, your terminal or desktop environment consumed it before Herdr could see it. Rebind either side: free the chord in the terminal's settings, or pick another chord in Herdr. diff --git a/docs/versions/0.9.1/website/src/content/docs/marketplace.mdx b/docs/versions/0.9.1/website/src/content/docs/marketplace.mdx new file mode 100644 index 00000000..4c12bdbe --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/marketplace.mdx @@ -0,0 +1,54 @@ +--- +title: Marketplace +description: Discover community Herdr plugins on GitHub, and get your own plugin listed. +--- + +The Herdr plugin marketplace indexes community plugins. Browse it at +[herdr.dev/plugins](/plugins/). The index covers public GitHub repositories; it +is not a reviewed catalog. + +## Browse plugins + +The [marketplace](/plugins/) lists public repositories tagged with the GitHub +topic `herdr-plugin` when their default branch contains at least one +`herdr-plugin.toml` whose required metadata can be parsed. Search by repository +or plugin metadata, and sort repository cards by popularity, recent activity, +or newest. Each card links to its source repository and lists every discovered +plugin inside it. + +Discovery is automatic and unreviewed. A listing means a repository tagged +itself, not that Herdr vetted it, so the +[trust guidance](/docs/plugins/#trust-and-security) applies before you install +anything. + +## Install a plugin + +The marketplace helps you discover plugins. Install any plugin straight from +GitHub: + +```bash +herdr plugin install owner/repo[/subdir...] +``` + +The command works with a public GitHub repository that has a +`herdr-plugin.toml` manifest at its root or in a subdirectory. See +[Plugins](/docs/plugins/) for the manifest and authoring reference. + +## Get your plugin listed + +Add the GitHub topic `herdr-plugin` to a public repository and put one or more +`herdr-plugin.toml` manifests with parseable required metadata on its default +branch. Manifests may be at the root or in subdirectories. The marketplace uses +one card per repository and lists each valid manifest as a separately +installable plugin. The index refreshes automatically every 30 minutes and +rescans repositories when their default-branch head changes. + +## What a listing shows + +Each card shows GitHub repository metadata: the repository name and owner, its +description, star count, primary language, and the time it was last pushed. Its +plugin rows show each manifest's `name` and `version` and link to the exact +source directory. The index records the manifest path, `id`, `name`, `version`, +`platforms`, and `min_herdr_version` together with the exact default-branch +commit. Forks, archived repositories, repositories without a valid plugin +manifest, and malformed manifest metadata are excluded. diff --git a/docs/versions/0.9.1/website/src/content/docs/persistence-remote.mdx b/docs/versions/0.9.1/website/src/content/docs/persistence-remote.mdx new file mode 100644 index 00000000..4f42cef5 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/persistence-remote.mdx @@ -0,0 +1,159 @@ +--- +title: Persistence and remote access +description: Detach from Herdr, reattach later, use named sessions, and connect over SSH. +--- + +Herdr keeps panes running in a background server. Your terminal client can detach and reconnect later. + +For the local, SSH, and `herdr --remote` workflows, see [How to work with Herdr](/docs/how-to-work/). + +## Detach and reattach + +Detach the client with `ctrl+b q`; panes and agents keep running. Reattach by running `herdr` again. Stop the session and its panes with `herdr server stop`. + +When Herdr starts again after a full server stop, it restores the saved session shape. For what survives detach, server restart, screen history replay, native agent session restore, and live handoff, see [Session state and restore](/docs/session-state/). + +## Named sessions + +Use named sessions when you want independent Herdr servers. + +```bash +herdr session list +herdr session attach work +herdr session attach side-project +herdr session stop work +herdr session delete side-project +``` + +A named session has its own panes, tabs, workspaces, sockets, and runtime state. It still shares the same global config file. + +When deleting a session, use the exact name shown by `herdr session list`, including letter case. On case-insensitive filesystems, `herdr session delete foo` refuses to delete a session named `Foo`. + +Use `--json` for scripts: + +```bash +herdr session list --json +herdr session stop work --json +herdr session delete side-project --json +``` + +## Saved SSH machines + +To keep Local and several SSH machines in one Herdr window, see [Connecting machines](/docs/connecting-machines/). That guide covers setup, switching, reconnects, settings, and remote automation. Removing a saved machine only disconnects the client; it does not stop that machine's sessions. + +## Remote attach over SSH + +[How to work with Herdr](/docs/how-to-work/) compares the connection paths. SSH to the server and run `herdr` there for the tmux-style path, use [saved SSH machines](/docs/connecting-machines/) for multi-machine work, or attach through SSH from your local machine: + +```bash +herdr --remote workbox +herdr --remote ssh://you@server:2222 +``` + +In this mode, the remote server owns the running panes and sends their terminal content and session state over SSH. Your local Herdr draws the UI, including its sidebar, menus, and theme. Because the client runs locally, Herdr can bridge local desktop features such as image clipboard paste into the remote session by copying the image to a remote temp file and pasting that path. + +By default, `herdr --remote` uses your local Herdr keybindings for that attach. This keeps local muscle memory even when the remote server has different config. After editing local keybindings, use the UI's `reload config` action to apply them without detaching. Use `--remote-keybindings server` when you want the remote server config instead. Local custom command keybindings are not sent, because those commands would run on the remote host. + +For repeat targets, use your SSH config: + +```text +Host workbox + HostName server.example.com + User you + Port 2222 +``` + +Then attach with: + +```bash +herdr --remote workbox +``` + +Remote attach and saved SSH machines support Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64, or Windows hosts on x86_64. Local and remote versions do not need to match once both support the stable endpoint generation. On Linux and macOS hosts, Herdr prefers a compatible `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. On Windows hosts, Herdr checks `PATH` and the active managed release. If no compatible binary exists, interactive runs prompt to install or update one; non-interactive runs fail instead of modifying the host. Linux and macOS installs use `~/.local/bin/herdr`; Windows installs use the complete Windows package and its app-local ConPTY runtime. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. + +By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Linux and macOS clients also use a private per-attach control socket for connection reuse; Windows OpenSSH does not. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket. + +Remote attach uses your normal OpenSSH authentication. If the target uses a passphrase-protected key in a non-interactive shell, script, CI job, or mobile terminal that cannot show the passphrase prompt, load the key into ssh-agent first: + +```bash +ssh-add +herdr --remote workbox +``` + +For any remote authentication failure, verify plain SSH access first with `ssh workbox`, then run `herdr --remote workbox` again. + +A version difference alone does not replace or restart a running remote server. Remote attach asks before stopping a server that lacks required compatibility or detached-daemon support. For saved machines, this also includes the surface and health-check capabilities described in [Connecting machines](/docs/connecting-machines/#updates-and-saved-data). The default answer is No; stopping the server ends its pane processes. To opt into experimental live handoff when that one-time upgrade is needed, pass `--handoff`: + +```bash +herdr --remote workbox --handoff +``` + +If you SSH into the server first and run `herdr` there, Herdr runs entirely on the server and cannot access your local desktop clipboard beyond normal terminal text paste. + +For Linux and macOS hosts, Herdr can copy the current local binary when the platforms match. For Homebrew, mise, and Nix installs, when the platforms differ, or for Windows hosts, it downloads the matching release asset for the current client version from `https://herdr.dev/latest.json`. + +For local builds or custom packages, set `HERDR_REMOTE_BINARY` to a local file path before running remote attach. Use a bare Herdr executable for Linux or macOS targets and a complete `herdr-windows-x86_64.zip` package for Windows targets. + +```bash +HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox +``` + +## Remote named sessions + +Use `--session` with `--remote` to attach to a named session on the remote host: + +```bash +herdr --remote workbox --session agents +``` + +## Direct terminal attach + +Full Herdr attach opens the whole workspace UI. Direct attach opens one server-owned terminal in your current terminal. + +Direct terminal attach is available on Linux and macOS, not native Windows. + +Attach by agent target: + +```bash +herdr agent attach reviewer +``` + +Attach by terminal ID: + +```bash +herdr terminal attach term_abc123 +``` + +Direct attach streams the current rendered terminal state, then live ANSI frames. Input goes straight to that terminal. + +Detach with `ctrl+b q`. Send a literal `ctrl+b` with `ctrl+b ctrl+b`. + +Only one writable direct attach client owns input and resize for a terminal. Use `--takeover` to replace an existing owner: + +```bash +herdr terminal attach term_abc123 --takeover +``` + +For third-party bridges that only need rendered terminal bytes, use a read-only +terminal session observer: + +```bash +herdr terminal session observe w1:p1 --cols 120 --rows 40 +``` + +It prints newline-delimited JSON `terminal.frame` records with base64 ANSI +bytes, then a `terminal.closed` record when the server closes the stream. +Multiple observers can watch the same terminal without taking input, resize, +scroll, or takeover ownership. + +For an interactive bridge, use a writable terminal session controller: + +```bash +herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 +``` + +Control mode prints the same newline-delimited frame records and reads +newline-delimited JSON commands on stdin. `terminal.input` sends text or +base64 bytes, `terminal.resize` changes the controller viewport, +`terminal.scroll` scrolls the attached viewport, and `terminal.release` closes +the controller. Only one controller owns input and resize at a time. diff --git a/docs/versions/0.9.1/website/src/content/docs/plugins.mdx b/docs/versions/0.9.1/website/src/content/docs/plugins.mdx new file mode 100644 index 00000000..4085e242 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/plugins.mdx @@ -0,0 +1,374 @@ +--- +title: Plugins +description: Author local Herdr plugins with manifest actions, event hooks, and panes. +--- + +Herdr plugins are shareable, executable workflow packages. A plugin can be a +Bash script, JavaScript app, Lua script, Rust binary, or any other argv command +your machine can run. Herdr owns the host surface: installation, manifest +validation, keybindings, terminal panes, events, invocation context, and socket +access. The plugin owns its implementation language, dependencies, files, and +durable state. + +Plugins exist so Herdr can stay lean. The core stays focused on terminal +workspaces, panes, agents, and a stable CLI/socket API. Plugins turn that +existing extension surface into reusable workflows that people can build, +install, and share without adding every workflow to Herdr itself. + +A plugin is a directory with a `herdr-plugin.toml` manifest and commands Herdr +can launch. Herdr validates the manifest, injects runtime context, starts the +declared commands, and records logs. The commands call back into Herdr through +the CLI or socket when they need to do more work. + +There is no separate plugin SDK or restricted command set. The entire Herdr CLI +is the plugin API. Every command in the +[CLI reference](/docs/cli-reference/) is available to a plugin, and a plugin can +run anything you can run yourself as `herdr ...`. Most plugins should call Herdr +through `HERDR_BIN_PATH`, which points at the running Herdr binary. That keeps +plugins portable across Unix sockets and Windows named pipes. Use the +[socket API](/docs/socket-api/) when you want to send raw JSON requests yourself. + +Runtime action registration and native non-terminal plugin UI are not part of +plugin v1. Actions, event hooks, panes, and link handlers are all declared in +the manifest. + +## Trust and security + +A plugin is ordinary code that runs on your machine. Its build and runtime +commands run as your user, inherit your environment, and can call the full Herdr +CLI. Treat a plugin like any extension you add to an editor, shell, or coding +agent. + +Install or link plugins only from authors and repositories you trust. Before +installing or linking one, skim the `herdr-plugin.toml` manifest and the scripts +or binaries it runs. `herdr plugin install` shows a preview of the source and +the commands it will run in interactive terminals, so you can review before +confirming. Use `--yes` for +sources you already trust, and pin `--ref` when you want a specific revision. + +Herdr validates the manifest and keeps each plugin's config and state in its own +directory, but it does not review or sandbox plugin code. Third-party plugins +come from their authors, not Herdr; you are responsible for deciding whether to +run them. + +## Manifest + +The manifest is the contract between Herdr and the plugin. It declares package +metadata, supported platforms, optional build commands, and the entrypoints +Herdr can run. + +```toml +id = "example.layout" +name = "Layout" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Apply project layouts" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["npm", "ci"] + +[[build]] +command = ["npm", "run", "build"] +platforms = ["linux", "macos"] + +[[startup]] +command = ["node", "dist/restore.js"] + +[[actions]] +id = "apply" +title = "Apply layout" +contexts = ["workspace"] +command = ["node", "dist/apply.js"] + +[[events]] +on = "worktree.created" +command = ["herdr", "workspace", "list"] + +[[panes]] +id = "board" +title = "Project board" +placement = "overlay" +command = ["herdr-board"] + +[[link_handlers]] +id = "github-issue" +title = "Open GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "apply" +``` + +Top-level `id`, `name`, `version`, and `min_herdr_version` are required. +Set `min_herdr_version` to the oldest Herdr version that supports the plugin +APIs, event names, and manifest fields your plugin uses. Herdr refuses to link +or install a plugin when its minimum version is newer than the current binary. +`description` is optional. Plugin ids may use ASCII letters, digits, dot, +colon, underscore, and hyphen. + +Action ids, pane ids, and link handler ids are local ids inside the plugin. +They may use ASCII letters, digits, colon, underscore, and hyphen, but not +dots. Each id type must be unique inside a plugin. Herdr qualifies action ids +as `plugin.id.action` when it needs a globally unique name. + +Use `platforms = ["linux", "macos", "windows"]` to declare where the plugin +can run. Build commands, startup hooks, actions, event hooks, panes, and link +handlers can also declare their own `platforms`; item-level platforms override +the top-level list. Local plugins without top-level `platforms` link with a +warning. + +`command` values are argv arrays. Herdr does not run them through a shell, so +there is no shell expansion unless your command starts a shell itself. Put +language-specific behavior in your script or binary. + +## First plugin + +Start with a directory that contains `herdr-plugin.toml` and one executable +script or program: + +```text +my-plugin/ + herdr-plugin.toml + index.js +``` + +```toml +id = "example.workspace-tools" +name = "Workspace Tools" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Small workspace helpers" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "list-workspaces" +title = "List workspaces" +contexts = ["workspace"] +command = ["node", "index.js"] +``` + +Inside the command, call back into Herdr with `HERDR_BIN_PATH`: + +```js +const { spawnSync } = require("node:child_process"); + +const herdr = process.env.HERDR_BIN_PATH ?? "herdr"; +const result = spawnSync(herdr, ["workspace", "list"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}); + +process.stdout.write(result.stdout); +process.stderr.write(result.stderr); +process.exit(result.status ?? 1); +``` + +This example uses Node, but nothing about plugins requires Node. The manifest +could launch Bash, PowerShell, Python, Rust, Go, Lua, Bun, or any other command +available on the user's machine. + +## Install and link + +Install an example plugin: + +```bash +herdr plugin install ogulcancelik/herdr-plugin-examples/agent-telegram-notify +herdr plugin config-dir examples.agent-telegram-notify +herdr plugin list +herdr plugin action list --plugin examples.agent-telegram-notify +``` + +When you are authoring a local plugin, link the working directory instead: + +```bash +herdr plugin link /path/to/plugin +herdr plugin config-dir example.layout +herdr plugin action list --plugin example.layout +herdr plugin action invoke example.layout.apply +herdr plugin pane open --plugin example.layout --entrypoint board +herdr plugin log list --plugin example.layout +``` + +`plugin install` accepts GitHub shorthand only, such as +`owner/repo/subdir`. It clones with `git`, shows a preview in interactive +terminals, runs supported build commands, then stores the checkout under +Herdr-managed plugin data and registers it. Use `--yes` for noninteractive +installs. Reinstalling a GitHub-managed plugin replaces that managed checkout. +Installed and linked plugins, including their enabled state, are global to the +current user and available in every Herdr session. Both `plugin install` and +`plugin link` can register plugins while no Herdr server is running. Plugins +installed only in a named session on Herdr 0.7.3 must be installed or linked +again. Existing plugin config and state remain in place. +Installing over a locally linked plugin is refused; unlink or uninstall the +local plugin first. `plugin install` and `plugin link` create the plugin's +config and state directories, and `plugin config-dir <id>` prints the config +directory for setup docs and shell scripts. + +`plugin uninstall <id-or-source>` unregisters the plugin. For GitHub-managed +installs it also removes the managed checkout, and it accepts either the plugin +id or the same `owner/repo[/subdir...]` shorthand used by install. +`plugin unlink <id>` only unregisters a plugin and leaves files alone, which is +useful for local development. There is no separate `plugin update` in v1; +reinstall from GitHub to refresh a managed plugin. + +The example cookbook repo is `ogulcancelik/herdr-plugin-examples`. It contains +separate example plugins in subdirectories, including `agent-telegram-notify`, +`github-link-preview`, and `dev-layout-bootstrap`. Use them as examples to copy; +Herdr does not maintain them as official plugins. + +## Build commands + +Build commands run during GitHub `plugin install` after confirmation and before +Herdr registers the plugin. If a build command fails, install aborts and the +plugin is not registered. `plugin link` does not run build commands; local +authors build their working tree themselves. Build commands may generate files, +but changing `herdr-plugin.toml` after the install preview aborts install. Build +failures show the plugin id, build index, working directory, command, exit +status or spawn error, and capped stdout/stderr without interpreting tool output. + +Build commands are plain argv commands too, but they do not receive runtime +plugin context or Herdr socket env. Plugin authors should document required +system tools such as `cargo`, `npm`, `bun`, or `lua`; Herdr reports build +failures but does not install missing toolchains. + +## Startup hooks + +`[[startup]]` commands run once for each enabled plugin after Herdr restores the +session and its API socket is ready. They run again when a new server takes over +during live handoff, but not when a client attaches, config reloads, or a plugin +is linked or enabled. Herdr starts them asynchronously and records their +completion in the normal plugin command log. A startup failure does not stop the +server. + +Startup hooks are one-shot initialization commands rather than supervised +daemons. A hook should restore plugin-owned state, call any required Herdr APIs, +and exit. For example, a plugin can save a declarative Agent view under +`HERDR_PLUGIN_STATE_DIR`, then read and reapply that view from its startup hook. + +Startup hooks receive the normal runtime plugin environment and +`HERDR_PLUGIN_EVENT=startup`. The install preview lists every startup command so +users can review code that will run automatically. + +## Commands and environment + +Runtime commands run with the plugin directory as their working directory. Herdr +injects `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ENV=1`, +`HERDR_PLUGIN_ID`, `HERDR_PLUGIN_ROOT`, `HERDR_PLUGIN_CONFIG_DIR`, +`HERDR_PLUGIN_STATE_DIR`, `HERDR_PLUGIN_CONTEXT_JSON`, and any available +`HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, and `HERDR_PANE_ID`. Action commands also +receive `HERDR_PLUGIN_ACTION_ID`; startup and event hooks receive +`HERDR_PLUGIN_EVENT` (`startup` for startup hooks), event hooks additionally +receive `HERDR_PLUGIN_EVENT_JSON`, and pane commands receive +`HERDR_PLUGIN_ENTRYPOINT_ID`. + +`HERDR_PLUGIN_ROOT` is the installed or linked plugin directory. Do not store +user credentials or durable state there, because GitHub-installed plugin roots +are managed source checkouts. Put user-editable config such as `.env` files +under `HERDR_PLUGIN_CONFIG_DIR`, and put local runtime state under +`HERDR_PLUGIN_STATE_DIR`. Herdr creates those directories and seeds +`HERDR_PLUGIN_CONFIG_DIR` from the legacy plugin config locations when present, +but it does not validate, sync, or delete their contents. The plugin owns the +file format and lifecycle. + +`HERDR_PLUGIN_CONTEXT_JSON` can include workspace, tab, focused pane, worktree, +agent, selected text, clicked URL, and link handler fields when they are +available for that invocation. Shell plugins can read the individual env vars +for common ids, or parse the context JSON for the full shape. + +Use `HERDR_BIN_PATH` when a plugin needs to call Herdr portably from Node, +PowerShell, Bash, or another runtime. The raw socket transport behind +`HERDR_SOCKET_PATH` is OS-specific: Unix clients connect to a Unix socket path, +while Windows clients connect to a named pipe. CLI calls through +`HERDR_BIN_PATH` avoid that transport difference. See the +[CLI reference](/docs/cli-reference/) for available commands and +[socket API](/docs/socket-api/) for raw request shapes. + +## Panes + +Manifest pane `placement` defaults to `overlay`, which opens a temporary zoomed +overlay over the active pane and restores the previous focus and zoom when it +closes. A `plugin.pane.open` request can override the manifest placement with +`overlay`, `popup`, `split`, `tab`, or `zoomed`. + +`placement = "popup"` opens a session-modal terminal popup without changing the +tiled layout. It accepts optional `width` and `height` fields in the manifest or +open request; omit them for the default half-size popup, use numbers for outer +terminal-cell dimensions, or use strings like `"80%"` for a percentage of the +terminal area. It receives all terminal input, including Escape, and closes +when the command exits or a `popup.close` request is sent. Dimensions smaller +than the popup minimum are clamped. + +Declare the placement directly on a plugin pane entrypoint when the pane should +always be transient: + +```toml +[[panes]] +id = "picker" +title = "Picker" +platforms = ["linux", "macos"] +placement = "popup" +width = "80%" +height = 20 +command = ["sh", "picker.sh"] +``` + +Split, tab, zoomed, and overlay plugin panes are normal Herdr panes after they +open. Plugins can call standard pane APIs such as `pane.move`, `pane.swap`, +`pane.resize`, and `pane.zoom` through the socket or CLI; Herdr keeps plugin +pane ownership attached to the underlying pane when it moves across tabs or +workspaces. A popup is a singleton session resource rather than a Herdr pane: +it has no pane ID, does not change plugin focus context, emits no pane lifecycle +events, and does not participate in pane, layout, persistence, or agent APIs. +Its process does not receive `HERDR_PANE_ID`; the underlying tiled pane remains +available through `HERDR_PLUGIN_CONTEXT_JSON`. +Opening a popup returns `ui_busy` while Settings, Copy mode, or another Herdr +modal is active, and `plugin.pane.open` returns an `ok` result after launch. + +On Windows, build commands, action commands, and event commands resolve common +`PATHEXT` shims such as `npm.cmd`, `bun.cmd`, and `pnpm.cmd` when the bare +command is on `PATH`. Pane commands use Herdr's normal Windows pane launcher and +must still be valid Windows argv commands. + +## Keybindings + +Bind a key to an installed plugin action: + +```toml +[[keys.command]] +key = "prefix+l" +type = "plugin_action" +command = "example.layout.apply" +description = "apply layout" +``` + +## Link handlers + +Use `[[link_handlers]]` to route modified clicks on matching terminal URLs to a +plugin action instead of opening the URL in the browser. The modified-click +modifier is Control on every platform, including macOS, because captured +terminal mouse reports do not expose Command/Super separately from a plain +click. `pattern` is a Rust regular expression matched against the clicked URL, +and `action` must +name an action declared by the same plugin. Link handler actions receive +`invocation_source = "link_click"`, `clicked_url`, and `link_handler_id` in +`HERDR_PLUGIN_CONTEXT_JSON`; shell plugins can also read +`HERDR_PLUGIN_CLICKED_URL` and `HERDR_PLUGIN_LINK_HANDLER_ID`. Handlers are +checked in manifest order inside each plugin. + +## Storage + +There is no Herdr-managed plugin storage API in v1. Plugins that need durable +state should own their files or database. + +## Marketplace + +Community plugins are discoverable in the [marketplace](/plugins/), an automatic +index of public GitHub repositories tagged with `herdr-plugin` that contain one +or more `herdr-plugin.toml` files whose required metadata can be parsed. Plugins +stay ordinary GitHub repositories: publish one, then share +`herdr plugin install owner/repo[/subdir]`. + +To get plugins listed, add the GitHub topic `herdr-plugin` and place their +manifests at the root or in subdirectories of the repository's default branch. +One repository card can contain multiple separately installable plugins. The +index refreshes every 30 minutes. See +[Marketplace](/docs/marketplace/) for how discovery works. diff --git a/docs/versions/0.9.1/website/src/content/docs/quick-start.mdx b/docs/versions/0.9.1/website/src/content/docs/quick-start.mdx new file mode 100644 index 00000000..e5c45e4a --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/quick-start.mdx @@ -0,0 +1,69 @@ +--- +title: Quick start +description: Create your first Herdr workspace and run agents in persistent terminal panes. +--- + +If Herdr is not installed yet, see [Install](/docs/install/). Then start Herdr from any project directory: + +```bash +herdr +``` + +Herdr launches or attaches to your default background session. You do not manage sockets. If you detach, agents keep running. + +## Create a workspace + +When a session has no workspaces, Herdr opens one automatically. A workspace is a project-level container for tabs, panes, and agents. Give each active project its own workspace to keep agent state readable in the sidebar. + +## Use the mouse + +Herdr is mouse-native, so start by clicking panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard. Double-click a token to select it, or hold the second press and drag to extend the selection by whole words. Both gestures copy when you release the mouse. Copying does not require Ctrl+C. + +Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and `http://` or `https://` URLs, including wrapped links whose beginning or end is outside the visible area. Hold Ctrl and move the pointer over a link to underline its visible segments. Hover highlighting clears when you move away, the pane content changes, or Herdr receives a Ctrl-release event; some terminals only report modifier changes with mouse movement. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`. + +If you configure `ui.right_click_passthrough_modifier`, that modifier plus right-click sends right-click, hold, and drag gestures to mouse-reporting pane apps. To make normal right-click go to one pane app, choose **Send right-clicks to pane** from that pane's menu or run `herdr pane input --current --right-click pane` inside it. Right-click the pane frame to reopen Herdr's menu. + +## Run an agent + +Start your coding agent in a pane: + +```bash +claude +``` + +Or `codex`, `pi`, `opencode`, or any other [supported agent](/docs/agents/). Herdr detects it automatically. Across every workspace, the sidebar shows whether each agent is `working`, `blocked`, `done`, or `idle`, so you always know which project needs you. + +## Keyboard control + +Keyboard control is optional; the mouse covers everything. Press `ctrl+b` to enter prefix mode, then press an action key. + +Common actions: + +| Action | Key | +| --- | --- | +| Split right | `prefix+v` | +| Split down | `prefix+minus` | +| New tab | `prefix+c` | +| Next / previous tab | `prefix+n` / `prefix+p` | +| Workspace navigation | `prefix+w` | +| New workspace | `prefix+shift+n` | +| Detach client | `prefix+q` | + +If the prefix idea is new to you, [Keyboard](/docs/keyboard/) explains what it is, why multiplexers use one, and how to go prefix-free. Press `prefix+?` inside Herdr to see every active binding, and `prefix+[` to copy from the keyboard in copy mode. + +## Detach and come back + +Press `prefix+q` or simply close your terminal window. The Herdr server and every agent keep running. Run `herdr` again to reattach to the same session. + +To end the session and stop its panes: + +```bash +herdr server stop +``` + +## Where next + +- [Concepts](/docs/concepts/) — the workspace, tab, pane, and agent model in two minutes. +- [How to work with Herdr](/docs/how-to-work/) — local, SSH, phone, and `herdr --remote` workflows. +- [Agents](/docs/agents/) — supported agents, detection, and integrations that improve state accuracy. +- [Configuration](/docs/configuration/) — keybindings, themes, notifications, and everything else. diff --git a/docs/versions/0.9.1/website/src/content/docs/session-state.mdx b/docs/versions/0.9.1/website/src/content/docs/session-state.mdx new file mode 100644 index 00000000..fd065483 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/session-state.mdx @@ -0,0 +1,115 @@ +--- +title: Session state and restore +description: Understand what Herdr keeps live, restores after restart, replays from history, resumes through agent integrations, and hands off during updates. +--- + +Herdr uses several state paths for different situations. + +## What survives + +| Case | Processes keep running | Layout returns | Recent screen returns | Agent conversation resumes | +| --- | --- | --- | --- | --- | +| Detach and reattach | Yes | Yes | Yes, from the live terminal | Yes, because the process never stopped | +| Server restart | No | Yes | Only with pane screen history | Only with native agent session restore | +| Update without `--handoff` | Compatible servers keep running; restart-required servers may need stop/restart | Yes after restart | Only with pane screen history | Only with native agent session restore | +| Update with `--handoff` | Best effort for supported running servers | Yes | Yes, from the live terminal if handoff succeeds | Yes, because the process keeps running if handoff succeeds | + +## Live persistence + +Normal detach keeps the Herdr server running. Panes, shells, agents, servers, tests, and command processes keep running inside that server. + +Detach the client with `ctrl+b q`. Reattach later: + +```bash +herdr +``` + +This is the strongest persistence path because the original processes never stop. + +## Snapshot restore + +If the Herdr server stops and starts again, the original pane processes are gone. Herdr restores the saved session shape: workspaces, tabs, panes, cwd, layout, and focus. + +Snapshot restore does not preserve running shells, servers, tests, or arbitrary processes. Panes that cannot use a stronger restore path come back as new shells in their saved directories. + +If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. Healthy restores do not create recovery copies. + +Herdr retains the three most recent recovery copies, pruning older copies only after a new copy is safely written. If preservation fails, autosave and shutdown leave the original untouched and log the failure; preservation is retried on the next save request. Copies are never restored automatically. To recover one, stop the affected server, copy the recovery file over its `session.json`, then restart. Recovery copies do not include pane screen history. + +## Pane screen history replay + +Pane screen history restores recent terminal contents after a full server restart without restoring the old process. + +Pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. Enable it in the config file: + +```toml +[experimental] +pane_history = true +``` + +When enabled, Herdr stores saved pane history in `session-history.json` next to `session.json`. Treat the Herdr config/session directory like terminal history. + +## Native agent session restore + +Some agents can resume their own conversation sessions. Herdr can use official integration-reported session references to restart supported agent panes after a Herdr server restart. + +Native agent session restore is enabled by default. Disable it with: + +```toml +[session] +resume_agents_on_restore = false +``` + +Herdr only resumes panes that reported a native session reference through a current official Herdr integration. + +After a client attaches and provides terminal size and theme context, Herdr resumes eligible restored agent panes across workspaces and tabs without waiting for each pane to be focused. + +Native session restore requires these Herdr integration versions or newer: + +| Agent | Minimum Herdr integration version | Resume command | +| --- | --- | --- | +| Pi | `2` | `pi --session <path-or-id>` | +| Antigravity CLI | `1` | `agy --conversation <id>` | +| OMP | `3` | `omp --resume=<path-or-id>` | +| Claude Code | `6` | `claude --resume <id>` | +| Codex | `5` | `codex resume <id>` | +| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | +| Grok CLI | `2` | `grok --resume <id>` | +| GitHub Copilot CLI | `2` | `copilot --resume=<id>` | +| Devin CLI | `2` | `devin --resume <id>` | +| Droid | `2` | `droid --resume <id>` | +| Kimi Code CLI | `3` | `kimi --session <id>` | +| Qoder CLI | `2` | `qodercli --resume <id>` | +| Qwen Code | `1` | `qwen --resume <id>` | +| Letta Code | `1` | `letta --conversation <id>` or `letta --conversation default --agent <agent-id>` for `default:<agent-id>` | +| OpenCode | `5` | `opencode --session <id>` | +| Kilo Code CLI | `1` | `kilo --session <id>` | +| Hermes Agent | `2` | `hermes --resume <id>` | +| MastraCode | `1` | `mastracode --thread <id>` | + +Run `herdr integration status` to check installed integration versions. Reinstall outdated integrations with `herdr integration install <agent>`. + +Unsupported, missing, invalid, duplicated, or stale session references restore as normal shells in the saved pane directory. + +If native agent session restore applies to a pane, Herdr resumes the agent session instead of replaying saved pane history for that pane. + +## Live handoff + +Live handoff is for update and remote attach flows that need to replace a running Herdr server. It asks the old server to transfer live panes to the new server, so pane processes can keep running across the server replacement. + +Unlike snapshot restore, pane history replay, and native agent session restore, handoff tries to keep the current processes alive rather than reconstructing state after the old server stops. + +A successful handoff preserves long-lived server-owned session state: pane PTYs and processes, agent identity and durable metadata, and plugin/session state needed by the replacement server. It does not preserve transient coordination across the replacement boundary. In-flight CLI or API requests, waits, subscription streams, client sockets, and pane-to-pane messages may be interrupted; clients should reconnect and retry them. + +Updated Unix servers can send more than 64 panes in one handoff by transferring them in batches. The sending server must already contain this support: an older running server still enforces its 64-pane limit on the first upgrade. That limit may require a normal server restart, which ends pane processes. Batching does not change update installation order or remove other handoff compatibility requirements. + +Live handoff is experimental and opt-in: + +```bash +herdr update --handoff +herdr --remote workbox --handoff +``` + +Plain `herdr update` installs the new client and keeps endpoint-generation-1 servers running. Plain `herdr --remote workbox` also keeps a compatible remote server running across version differences. A stop is required only for the one-time upgrade from a pre-generation-1 server; use `--handoff` when you explicitly want to replace a supported running server without losing its pane processes. + +`herdr update --handoff` only applies to installs managed by Herdr's own updater. Homebrew, mise, and Nix installs are updated through their package managers, so `herdr update` is disabled there and cannot perform live handoff. diff --git a/docs/versions/0.9.1/website/src/content/docs/socket-api.mdx b/docs/versions/0.9.1/website/src/content/docs/socket-api.mdx new file mode 100644 index 00000000..5bd933dd --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/socket-api.mdx @@ -0,0 +1,959 @@ +--- +title: Socket API +description: Control a running Herdr server from scripts, tools, and coding agents. +--- + +Herdr exposes a local socket API for scripts and agents that need to inspect or control a running session. + +Most automation should start with the CLI wrappers. Use the raw socket API only when you need direct request/response control or long-lived event subscriptions. + +## Choose an integration layer + +| Layer | Use it for | +| --- | --- | +| Agent skill | Teaching a coding agent how to use Herdr from inside a pane. | +| CLI wrappers | Shell scripts, simple orchestration, and human debugging. | +| Raw socket API | Custom tools, protocol clients, and event subscribers. | + +The layers share the same control surface. + +## Schema + +The installed CLI can print the socket protocol schema bundled with that Herdr +binary: + +```bash +herdr api schema +herdr api schema --json +herdr api schema --output herdr-api.schema.json +``` + +Plain `herdr api schema` prints a short summary. `--json` prints the full JSON +Schema document for tools, and `--output PATH` writes that document to a file. +The schema covers raw requests, success responses, error responses, emitted +events, and subscription events. + +## What you can control + +The socket API can: + +- create, list, focus, rename, and close workspaces +- create, list, focus, rename, and close tabs +- list, inspect, split, swap, focus, resize, rename, read, close, and send input to panes +- list, inspect, read, prompt, wait on, rename, focus, start, and attach agents through CLI helpers +- report custom agent state from hooks and plugins +- subscribe to events and wait for output or state changes +- install and uninstall built-in integrations +- stop the server and reload config + +## CLI examples + +Create a workspace: + +```bash +herdr workspace create --cwd ~/project --label api +``` + +Create a tab: + +```bash +herdr tab create --label logs +``` + +Split a pane and run a command: + +```bash +herdr pane split w1:p1 --direction right +herdr pane run w1:p2 "npm test" +``` + +Inspect and rearrange panes: + +```bash +herdr pane layout --current +herdr pane neighbor --direction right --current +herdr pane resize --direction right --amount 0.1 --current +herdr pane swap --direction right --current +herdr pane zoom --on --current +herdr pane split w1:p1 --direction right --ratio 0.333 +``` + +Wait for an agent: + +```bash +herdr agent wait w1:p1 --until done +``` + +Read pane output: + +```bash +herdr pane read w1:p2 --source recent --lines 50 +``` + +## Raw methods + +Raw socket method names use dot notation: + +| Area | Methods | +| --- | --- | +| Server | `ping`, `server.stop`, `server.reload_config`, `server.agent_manifests`, `server.reload_agent_manifests` | +| Notification | `notification.show` | +| Client | `client.window_title.set`, `client.window_title.clear` | +| Session | `session.snapshot` | +| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.move_block`, `workspace.report_metadata`, `workspace.close` | +| Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | +| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` | +| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.graphics.info`, `pane.graphics.set`, `pane.graphics.clear`, `pane.graphics.stream`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | +| Popup | `popup.close` | +| Layout | `layout.export`, `layout.apply`, `layout.set_split_ratio` | +| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send_keys`, `agent.prompt`, `agent.wait`, `agent.rename`, `agent.focus`, `agent.start`, `agent.view.set`, `agent.view.clear` | +| Events | `events.subscribe`, `events.wait` | +| Integrations | `integration.install`, `integration.uninstall` | +| Plugins | `plugin.link`, `plugin.list`, `plugin.unlink`, `plugin.enable`, `plugin.disable`, `plugin.action.list`, `plugin.action.invoke`, `plugin.log.list`, `plugin.pane.open`, `plugin.pane.focus`, `plugin.pane.close` | + +`agent.wait` is server-owned and event-driven. It pins the resolved pane occupant so a replacement cannot satisfy the wait. `agent.prompt` accepts an optional `wait` object with `until` and `timeout_ms`; this submits the prompt and starts the wait in one request, avoiding a race between separate calls. If the resolved agent is already `blocked`, `agent.prompt` returns `agent_blocked` without sending input or starting the wait. + +`workspace.move_block` atomically moves the ordered `workspace_ids` before `before_workspace_id`; omit the anchor to move the block to the end. The ids must be unique and the anchor cannot be part of the block. The response contains the authoritative ordered workspace list. + +`session.snapshot` returns a one-time bootstrap snapshot for clients that keep +their own local runtime cache. The response includes version/protocol metadata, +focused workspace/tab/pane ids, workspace records, tab records, pane records, +tab layout snapshots, and agent records. It is not a subscription. To avoid a +bootstrap gap, first open `events.subscribe` on another connection and wait for +its acknowledgement. Buffer that stream while calling `session.snapshot`, +install the snapshot, then apply the buffered events in order and continue +streaming. Call `session.snapshot` again after reconnecting or when the local +cache may be stale. Attached worktree provenance is included on workspace +records. Full repo worktree discovery remains `worktree.list`. + +From the CLI, `herdr api snapshot` prints the live `session.snapshot` response +as JSON for clients and agents that want a simple bootstrap command. + +Pane control methods use public pane ids such as `w1:p1`. Methods whose +schema makes `pane_id` optional use the server's active focused pane when it is +omitted. `pane.move` always requires the source `pane_id`. + +`pane.send_keys` and `pane.send_input.keys` accept Herdr key-combo strings: +plain printable keys, special keys like `enter` and `esc`, modifier chords +like `ctrl+h`, `control+j`, `alt+x`, and `shift+tab`, function keys like +`f1`, and named punctuation like `minus` and `plus`. They do not accept +`prefix+` binding strings. + +```json +{"id":"req_current","method":"pane.current","params":{"caller_pane_id":"w1:p1"}} +{"id":"req_layout","method":"pane.layout","params":{"pane_id":"w1:p1"}} +{"id":"req_neighbor","method":"pane.neighbor","params":{"pane_id":"w1:p1","direction":"right"}} +{"id":"req_edges","method":"pane.edges","params":{"pane_id":"w1:p1"}} +{"id":"req_focus","method":"pane.focus_direction","params":{"direction":"right"}} +{"id":"req_resize","method":"pane.resize","params":{"pane_id":"w1:p1","direction":"right","amount":0.1}} +{"id":"req_zoom","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"toggle"}} +{"id":"req_input","method":"pane.input.set","params":{"pane_id":"w1:p1","right_click":"pane"}} +{"id":"req_split","method":"pane.split","params":{"direction":"right","ratio":0.333,"right_click":"pane","env":{"HERDR_ROLE":"tests"}}} +{"id":"req_process","method":"pane.process_info","params":{"pane_id":"w1:p1"}} +``` + +`pane.current` returns a single `PaneInfo`. When `caller_pane_id` is present, +Herdr returns that pane. When it is omitted, Herdr returns the active focused +pane. + +`pane.input.set` sets `right_click` to `herdr` or `pane` for one pane. `herdr` +is the default. `pane` forwards unmodified right-click hold and drag gestures +when the application requests terminal mouse reporting; otherwise Herdr falls +back to its pane menu. Right-clicking the pane frame always opens Herdr's menu. +`pane.split` accepts the same optional `right_click` value for the newly created +pane. + +`PaneInfo` includes `scroll` when terminal scroll metrics are available: + +```json +{ + "offset_from_bottom": 12, + "max_offset_from_bottom": 240, + "viewport_rows": 30 +} +``` + +Clients can treat `offset_from_bottom == 0` as at-bottom state. + +### Pane graphics + +Pane graphics let a plugin place image data over a pane. They are available by +default; when the server has `[terminal].kitty_graphics = false`, every pane +graphics method returns `feature_disabled`. Changing this setting requires a +server restart. Calling `pane.graphics.info` explicitly +activates capability discovery and returns the attached client's cell size, +file-frame options, pixel-mouse support, the 16-layer limit, and `pane_visible`. +`pane_visible` is true only when the target is in the active workspace and tab +and is not hidden by zoom. Short-lived UI modes do not change it. + +`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` accept an +optional `layer_id` (default `primary`). Set and stream also accept `z_index`; +layers are placed in stable `(z_index, layer_id)` order. Each stream exclusively owns its layer, and +closing it removes that layer. Inline frames accept `png`, `rgb`, `rgba`, or +`bgra`; BGRA is normalized once to owned RGBA. Herdr advances the host cache +one image transaction per render pass, so arbitrary layer sets progress without +an aggregate frame. Client transport keeps each transaction within its 32 MiB +wire limit. + +```json +{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} +{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} +{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} +``` + +For repeated frames, open a dedicated socket with `pane.graphics.stream`. After +Herdr replies with `ok`, send one JSON header and then exactly `data_length` raw +bytes per inline frame. Concurrent operations on that layer return +`stream_conflict`. + +```json +{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1","z_index":0}} +{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} +``` + +When `pane.graphics.info` advertises `file_frame_transport: "direct-kitty"`, an +eligible local Ghostty, kitty, or WezTerm client may submit an immutable private +`rgba` or `bgra` file with `file.path`, `sequence`, and `revision`. Direct Kitty +file transport is reserved for the default `primary` page layer; named secondary +layers use owned inline RGBA. BGRA is always copied, swizzled, and rendered +inline. Herdr replies with a `pane_graphics_frame_ack` only after the terminal +accepts the file, or after a safe owned inline fallback is installed. Confirmed +file-transport failure disables direct files for that client connection without +disabling exact pixel mouse. A timeout or client loss closes the stream without +acknowledging source reuse. Clients that cannot negotiate direct file transport +remain on the owned inline fallback. + +Direct files are always complete canonical `width * height * 4` RGBA frames. +`file_frame_max_bytes` is the limit that remains eligible for owned inline fallback. +Primary-layer RGBA files may use the larger `file_frame_direct_max_bytes` limit when +`file_frame_transport` is available. Frames above the fallback limit are acknowledged +only when the terminal accepts the direct transfer; rejection closes the stream. If a +frame cannot use owned inline fallback while its pane is temporarily hidden or cannot be +placed during a redraw, Herdr uploads the image without displaying it and replays its +placement when the pane becomes visible again. +`file_frame_damage: true` means Herdr accepts optional damage metadata for +producer-side canonical-ring efficiency; it still copies or presents the full file. +Resize and full redraw replay placements without retransmitting pixels. + +`pane.layout` returns the tab layout snapshot with `workspace_id`, `tab_id`, +`zoomed`, outer `area`, `focused_pane_id`, pane rects, and split rects/ratios. +`pane.neighbor` and `pane.edges` include that same layout snapshot so clients +can make the next decision without private layout state. + +`pane.process_info` returns the pane's shell pid, foreground process group id +when available, and foreground processes with pid, name, argv/cmdline, and cwd +when the platform exposes them. + +`layout.export` returns a portable tab layout tree. Omit `tab_id` and `pane_id` +to export the active tab, pass `tab_id` to export that tab, or pass `pane_id` to +export the tab containing that pane. + +```json +{"id":"req_export","method":"layout.export","params":{"tab_id":"w1:t1"}} +``` + +The response includes `workspace_id`, `tab_id`, `zoomed`, `focused_pane_id`, and +`root`. `root` is a BSP tree of `pane` and `split` nodes. Pane nodes can include +`pane_id`, `label`, `cwd`, and argv `command`. Split nodes use +`direction` (`right` or `down`), `ratio`, `first`, and `second`. + +`layout.apply` creates a fresh tab from a declarative tree. If `tab_id` is +provided, Herdr creates the replacement tab first and then closes the old tab. +This restores structure, labels, cwd, env, and optional argv commands; it does +not preserve live PTYs, scrollback, or running processes. + +```json +{ + "id": "req_apply", + "method": "layout.apply", + "params": { + "workspace_id": "wabc", + "tab_label": "dev", + "focus": true, + "root": { + "type": "split", + "direction": "right", + "ratio": 0.65, + "first": { + "type": "pane", + "label": "editor", + "cwd": "/repo" + }, + "second": { + "type": "pane", + "label": "tests", + "cwd": "/repo", + "command": ["sh", "-c", "just test"], + "env": { "HERDR_ROLE": "tests" } + } + } + } +} +``` + +`layout.set_split_ratio` updates an existing split in a tab layout. The response +is `type: "layout_split_ratio_set"` with the updated portable `layout`. + +```json +{"id":"req_ratio","method":"layout.set_split_ratio","params":{"tab_id":"w1:t1","path":[],"ratio":0.6}} +``` + +Process-launching methods accept an `env` object. Herdr applies those key/value +pairs to the newly launched process only. Herdr also injects `HERDR_SOCKET_PATH`, +`HERDR_ENV=1`, `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, and `HERDR_PANE_ID` into +managed pane processes. Herdr-managed variables are authoritative when they +conflict with caller-provided env. + +`pane.swap` supports directional and explicit forms: + +```json +{"id":"req_swap_dir","method":"pane.swap","params":{"pane_id":"w1:p1","direction":"right"}} +{"id":"req_swap_explicit","method":"pane.swap","params":{"source_pane_id":"w1:p1","target_pane_id":"w1:p2"}} +``` + +Swap is same-tab only. It preserves split shape, split ratios, pane ids, and +running processes. The response is `type: "pane_swap"` with `changed`, optional +`reason`, `source_pane_id`, optional `target_pane_id`, `focused_pane_id`, and +`layout`. Reason values are `no_neighbor`, `same_pane`, `not_found`, and +`cross_tab`. When a tab is zoomed, swap keeps zoom active and mutates the hidden +full-tab layout. + +`pane.move` moves a running pane to a different tab, a new tab, or a new +workspace: + +```json +{"id":"req_move_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"tab","tab_id":"w1:t2","target_pane_id":"w1:p3","split":"right","ratio":0.5},"focus":true}} +{"id":"req_move_new_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_tab","workspace_id":"w1","label":"logs"},"focus":true}} +{"id":"req_move_new_workspace","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_workspace","label":"logs","tab_label":"main"},"focus":true}} +``` + +Existing-tab moves require `split: "right" | "down"`. `target_pane_id` is +optional and defaults to the target tab's focused pane. Same-tab layout changes +remain `pane.swap`; moving to the source tab returns `changed: false` with +`reason: "same_tab"`. Moves involving a zoomed source or target tab return +`changed: false` with `reason: "zoomed_tab"`. + +The response is `type: "pane_move"` with `changed`, optional `reason`, +`previous_pane_id`, `previous_workspace_id`, `previous_tab_id`, the moved +`pane`, optional `source_layout`, `target_layout`, optional created workspace or +tab records, optional closed workspace or tab ids, and `focused_pane_id`. +Cross-workspace moves keep the internal pane and terminal alive but assign a new +public pane id in the destination workspace. Subscribers can listen for +`pane.moved`; Herdr does not emit fake pane close/create events for the moved +terminal process. + +`pane.zoom` toggles, enables, or disables zoom for the target pane's tab: + +```json +{"id":"req_zoom_toggle","method":"pane.zoom","params":{"pane_id":"w1:p1"}} +{"id":"req_zoom_on","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"on"}} +{"id":"req_zoom_off","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"off"}} +``` + +Omitting `pane_id` targets the server's active focused pane. The response is +`type: "pane_zoom"` with `changed`, `zoom_changed`, `focus_changed`, optional +`reason`, `pane_id`, `focused_pane_id`, `zoomed`, and `layout`. `changed` is +true when either zoom state or focus changed. Reason values are `single_pane`, +`already_zoomed`, and `already_unzoomed`. + +The CLI wrapper for `notification.show` is: + +```bash +herdr notification show "build failed" --body "api workspace" --position top-left --sound request +``` + +Show a user notification through the configured toast delivery: + +```json +{"id":"req_notify","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}} +``` + +`title` is required and must contain visible text after control characters and repeated whitespace are removed. `body` is optional. Herdr collapses newlines, tabs, carriage returns, and repeated whitespace into spaces, then trims notification text to 80 characters for `title` and 240 characters for `body`. An empty sanitized `title` returns `invalid_params`. `position` is optional and applies only when `ui.toast.delivery = "herdr"`; desktop positions are relative to the full Herdr frame, and omitted positions use `ui.toast.herdr.position`. Terminal, system, and off delivery ignore `position`. `sound` is optional and can be `none`, `done`, or `request`; it defaults to `none` and plays only when the notification is shown. + +The response reports whether anything was shown: + +```json +{"id":"req_notify","result":{"type":"notification_show","shown":true,"reason":"shown"}} +``` + +Possible reasons are `shown`, `disabled`, `rate_limited`, `no_foreground_client`, and `busy`. `disabled` means `ui.toast.delivery = "off"`. `busy` means an existing in-app toast was not replaced. Terminal and system delivery are best-effort through the current foreground attached Herdr client. + +Set or clear the foreground client's outer terminal window title: + +```json +{"id":"req_title","method":"client.window_title.set","params":{"title":"herdr api"}} +{"id":"req_title_clear","method":"client.window_title.clear","params":{}} +``` + +`client.window_title.clear` hands the title back to `ui.window_title`. The response is +`type: "client_window_title"` with `changed` and reason `set`, `cleared`, or +`no_foreground_client`. + +Worktree methods manage Git checkouts as Herdr workspaces. `worktree.create` creates a checkout and returns the new `workspace`, `tab`, `root_pane`, and `worktree` records. If the requested branch already exists locally, it checks out that branch; otherwise it creates the branch from the requested base or `HEAD`. `worktree.open` opens an existing checkout or returns the already-open workspace. `worktree.remove` runs `git worktree remove` against a linked child workspace and never deletes the branch. + +Create a worktree from a source workspace: + +```json +{"id":"req_1","method":"worktree.create","params":{"workspace_id":"w1","branch":"worktree/api","focus":false}} +``` + +Open an existing checkout: + +```json +{"id":"req_2","method":"worktree.open","params":{"workspace_id":"w1","branch":"worktree/api","focus":true}} +``` + +Remove a linked checkout: + +```json +{"id":"req_3","method":"worktree.remove","params":{"workspace_id":"2","force":false}} +``` + +Use at most one of `workspace_id` or `cwd` for `worktree.list`, `worktree.create`, and `worktree.open`; omit both to use the active workspace. Use exactly one of `path` or `branch` for `worktree.open`. Raw socket `cwd` and `path` values must be absolute; the CLI expands relative `--cwd` and `--path` values before sending requests. Workspace responses include optional `worktree` provenance when a workspace belongs to a Herdr worktree group. Worktree commands can emit `workspace.updated` when an existing workspace gains or changes worktree provenance. + +Worktree commands also emit lifecycle events. `worktree.create` emits `workspace.created`, `tab.created`, `pane.created`, and `worktree.created`. `worktree.open` emits `worktree.opened`, and it also emits workspace/tab/pane creation events when it opens a new Herdr workspace. `worktree.remove` emits `worktree.removed`; if the linked workspace is still open, it also emits `workspace.closed`. + +`workspace.close` rejects closing a primary workspace while linked-worktree workspaces are open unless its params include `"close_group": true`, returning `workspace_group_close_required` when explicit group intent is missing. An explicit group close emits one `workspace.closed` event for each workspace it closes. + +## Agent view queries + +`agent.view.set` installs one transient declarative projection for the built-in +Agents view. The projection is reevaluated whenever agent facts or current UI +context change. It controls the expanded and collapsed sidebar, mobile Agents +list, mouse targets, indexed focus, and next/previous Agent navigation. It does +not change `agent.list`, notifications, detection, or global attention counts. + +Show agents in the currently presented Space or agents needing attention +elsewhere, then order by attention and most recent state transition: + +```json +{ + "id": "view_set", + "method": "agent.view.set", + "params": { + "source": "plugin:example.agent-views", + "label": "focus", + "filter": { + "op": "any", + "filters": [ + { + "op": "eq", + "field": "workspace_id", + "value": {"context": "current_workspace_id"} + }, + { + "op": "in", + "field": "status", + "values": ["blocked", "done"] + } + ] + }, + "sort": [ + {"field": "attention", "order": "desc"}, + {"field": "state_change_seq", "order": "desc"} + ] + } +} +``` + +Filter nodes use `op` values `all`, `any`, `not`, `eq`, `in`, or `exists`. +Built-in filter fields are `status`, `workspace_id`, `tab_id`, `pane_id`, +`agent`, `seen`, and `state_change_seq`. Use `{"token":"name"}` as a field to +filter plugin-reported pane metadata. Values are strings, booleans, unsigned +numbers, or a context object. Context values are `current_workspace_id` and +`current_tab_id`, and may only be compared to the matching ID field. In a +saved-machine client, the selected server's view applies to the combined agent +list. Current workspace and tab context includes that selected machine, so an +identical ID on another machine does not match; independent filter branches, +such as `status` being `blocked`, still match agents on any connected machine. +Effective status values are `idle`, `working`, `blocked`, `done`, and `unknown`; +`done` means idle and not yet seen. + +Sort fields are `workspace_order`, `tab_order`, `pane_order`, `attention`, +`status`, `agent`, `seen`, `state_change_seq`, or `{"token":"name"}`. Sorts are +stable, evaluated in order, and accept `asc` or `desc`. Missing values stay +after present values. When `sort` is omitted, the existing +`ui.agent_panel_sort` policy remains active. A custom sort temporarily replaces +that policy without rewriting config. + +`source` identifies the owner. Plugins use `plugin:<HERDR_PLUGIN_ID>`; Herdr +rejects plugin-owned sets when that plugin is missing or disabled. Other callers +may use their own non-`plugin:` source. A successful set atomically replaces the +previous view. The view lasts until it is cleared, replaced, its owning plugin +is disabled, unlinked, or uninstalled, or the server exits. Plugins that want +durable behavior should save the query under `HERDR_PLUGIN_STATE_DIR` and +reapply it from a `[[startup]]` hook. + +Clear unconditionally, or only when the named source still owns the view: + +```json +{"id":"view_clear","method":"agent.view.clear","params":{}} +{"id":"view_clear_owned","method":"agent.view.clear","params":{"source":"plugin:example.agent-views"}} +``` + +A source mismatch leaves the active view unchanged. Set and clear responses use +`type: "agent_view"` and report `active`, `source`, and optional `label`. + +## Plugin APIs + +The plugin API is an early interface for executable workflow tools. A plugin is +a package with a `herdr-plugin.toml` manifest. The manifest declares startup +hooks, shareable actions, event hooks, terminal pane entrypoints, and link +handlers. Startup hooks run once after restore when the API is ready. +Actions and panes are manifest-only; runtime action registration and runtime +argv pane creation are not part of v1. + +Installed and linked plugins persist across restarts. Herdr writes a +`plugins.json` registry file alongside `session.json` on `plugin.link`, +`plugin.unlink`, `plugin.enable`, and `plugin.disable`. The +`herdr plugin install` and `herdr plugin link` CLIs also write the same registry +when Herdr is not running, then startup loads it automatically. On startup, +Herdr re-reads each manifest from its original path; if the file is missing or +unparseable, the entry is kept with a `warnings` field so `plugin.list` surfaces +it. + +Herdr validates event hook `on` values against known event names at link time. +An unrecognised name does not block the link, but the returned plugin info +includes a warning (e.g. `"unknown event 'worktree.craeted'"`). Check the +`warnings` field in the `plugin.link` and `plugin.list` responses. + +Link a local plugin manifest: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/path/to/plugin","enabled":true}} +``` + +`plugin.link` also accepts optional `source` metadata. The CLI uses this when it installs from GitHub so `plugin.list` can show origin, requested ref, resolved commit, and managed checkout path: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/managed/plugin/herdr-plugin.toml","enabled":true,"source":{"kind":"github","owner":"ogulcancelik","repo":"herdr-plugin-examples","subdir":"worktree-bootstrap","requested_ref":"main","resolved_commit":"abc123","managed_path":"/data/plugins/github/<managed-checkout>","installed_unix_ms":1780000000000}}} +``` + +The path can be a plugin directory containing `herdr-plugin.toml` or a direct +manifest path. The manifest shape is: + +```toml +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Prepare new worktrees" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["bun", "install"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap worktree" +contexts = ["workspace"] +command = ["bun", "run", "bootstrap.ts"] + +[[events]] +on = "worktree.created" +command = ["bun", "run", "bootstrap.ts"] + +[[panes]] +id = "board" +title = "Worktree board" +placement = "overlay" +command = ["bun", "run", "board.ts"] + +[[link_handlers]] +id = "github-issue" +title = "Open GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "bootstrap" +``` + +`min_herdr_version` is required. The server refuses to link a plugin when the +field is missing, invalid, or newer than the running Herdr binary. + +Declare `platforms` at the top level with the OS identifiers (`linux`, `macos`, +`windows`) your plugin supports. Omitting `platforms` is allowed for local +development. `plugin.link` succeeds, but the response includes a warning. +Individual build commands, actions, event hooks, panes, and link handlers can +declare their own `platforms` to override the plugin-level list; if omitted they +inherit from the plugin. Invoking an action or opening a pane whose effective +platforms do not include the current OS returns a `platform_unsupported` error. + +List, enable, disable, or unlink linked plugins: + +```json +{"id":"req_plugin_list","method":"plugin.list","params":{}} +{"id":"req_plugin_disable","method":"plugin.disable","params":{"plugin_id":"example.worktree-bootstrap"}} +{"id":"req_plugin_enable","method":"plugin.enable","params":{"plugin_id":"example.worktree-bootstrap"}} +{"id":"req_plugin_unlink","method":"plugin.unlink","params":{"plugin_id":"example.worktree-bootstrap"}} +``` + +Actions are resolved from the linked manifest. `plugin.action.list` returns all actions across installed plugins; pass `plugin_id` to filter. + +```json +{"id":"req_plugin_actions","method":"plugin.action.list","params":{}} +{"id":"req_plugin_actions_filtered","method":"plugin.action.list","params":{"plugin_id":"example.worktree-bootstrap"}} +``` + +`plugin.action.list` returns each action's effective `platforms` after applying +plugin-level inheritance. + +Invoke an action by its qualified id or bare action id: + +```json +{"id":"req_plugin_invoke","method":"plugin.action.invoke","params":{"action_id":"example.worktree-bootstrap.bootstrap","context":{"invocation_source":"keybinding"}}} +``` + +`plugin.action.invoke` resolves the manifest action, starts the manifest +command, and returns the Herdr-built invocation context plus the started command +log record. Missing context fields are filled from the active workspace, tab, +focused pane, worktree provenance, and request id. Invoking an action from a +disabled plugin returns a `plugin_disabled` error. + +Herdr injects `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ENV=1`, +`HERDR_PLUGIN_ID`, `HERDR_PLUGIN_ROOT`, `HERDR_PLUGIN_CONFIG_DIR`, +`HERDR_PLUGIN_STATE_DIR`, `HERDR_PLUGIN_CONTEXT_JSON`, and available +`HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, and `HERDR_PANE_ID` values. Action +commands also receive `HERDR_PLUGIN_ACTION_ID`; event hooks receive +`HERDR_PLUGIN_EVENT` and `HERDR_PLUGIN_EVENT_JSON`; pane commands receive +`HERDR_PLUGIN_ENTRYPOINT_ID`. + +List recent action and event command logs: + +```json +{"id":"req_plugin_logs","method":"plugin.log.list","params":{"plugin_id":"example.worktree-bootstrap","limit":20}} +``` + +Event hooks run for enabled installed plugins when Herdr emits a matching event +name such as `worktree.created`. + +There is no Herdr-managed plugin storage API in v1. `HERDR_PLUGIN_CONFIG_DIR` +and `HERDR_PLUGIN_STATE_DIR` are path discovery only; plugins own their files, +schemas, migrations, and cleanup. + +Open a managed terminal UI: + +```json +{"id":"req_plugin_pane","method":"plugin.pane.open","params":{"plugin_id":"example.board","entrypoint":"board","placement":"zoomed","target_pane_id":"w1:p1","env":{"HERDR_ROLE":"board"},"focus":true}} +``` + +`plugin.pane.open` requires an installed, enabled, platform-compatible plugin, +then launches the requested manifest `[[panes]]` entrypoint as an argv-backed +terminal pane. Manifest pane `placement` defaults to `overlay`; request +`placement` overrides the manifest with `overlay`, `popup`, `split`, `tab`, or +`zoomed`. Overlay and popup placements use the active tiled pane as launch +context. Popup terminals are session-modal and do not change the tab layout; +optional `width` and `height` fields set their outer size as terminal cells or +percentages such as `"80%"`. Omitted dimensions default to half the terminal +size, with too-small values clamped to the popup minimum. A popup has no pane +ID, remains outside all `pane.*` and agent APIs, emits no pane lifecycle events, +leaves plugin focus context on the underlying tiled pane, and does not export +`HERDR_PANE_ID` to its process. Popup launch returns `ok`; `popup.close` closes +the active popup and returns +`popup_not_open` when none exists. Split and zoomed panes target an existing +pane; tab panes can target a workspace. Split, tab, zoomed, and overlay panes +behave like normal Herdr panes, and `plugin.pane.focus` and `plugin.pane.close` +continue to operate on those panes. + +## Socket transport + +Herdr uses newline-delimited JSON over a local socket. On Unix, that socket is a +Unix domain socket. On Windows, it is a named pipe. + +Send one request per line: + +```json +{"id":"req_1","method":"ping","params":{}} +``` + +A successful response includes the same `id`: + +```json +{"id":"req_1","result":{"type":"pong"}} +``` + +Event subscriptions keep the connection open after the initial response. + +## Socket paths + +The default socket lives under your Herdr config directory. + +Named sessions have separate sockets: + +```text +~/.config/herdr/herdr.sock +~/.config/herdr/sessions/<name>/herdr.sock +``` + +Resolution order: + +1. explicit CLI `--session <name>` +2. `HERDR_SOCKET_PATH` +3. `HERDR_SESSION=<name>` +4. default session socket + +Use `HERDR_SOCKET_PATH` only for low-level overrides. + +For plugins, prefer invoking `HERDR_BIN_PATH` and the CLI wrappers when you need +portable Windows behavior. Raw socket clients are responsible for using the +platform-native local socket form. + +## Agent state reporting + +Integrations report agent state with `pane.report_agent`. + +```json +{ + "id": "req_1", + "method": "pane.report_agent", + "params": { + "pane_id": "w1:p1", + "source": "custom:docs", + "agent": "docs-bot", + "state": "working", + "message": "building docs" + } +} +``` + +`state` carries semantic agent state and affects waits, notifications, and +rollups. Report display-only values separately through metadata. + +Session-only official integrations report native session references with `pane.report_agent_session`. State-reporting integrations can still include native session references in `pane.report_agent`. State-independent session reports do not affect waits, notifications, or rollups. + +```json +{ + "id": "req_2", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "herdr:codex", + "agent": "codex", + "agent_session_id": "..." + } +} +``` + +`pane.get`, `pane.list`, `agent.get`, and `agent.list` expose a read-only `agent_session` object when Herdr has a stored native session reference: + +```json +{ + "agent_session": { + "source": "herdr:codex", + "agent": "codex", + "kind": "id", + "value": "..." + } +} +``` + +If no native session reference is stored, the field is omitted. + +`pane.get`, `pane.list`, `agent.get`, and `agent.list` also expose `foreground_cwd` when Herdr can resolve the cwd of the process currently controlling the pane PTY. The existing `cwd` field remains the pane/workspace cwd used for labels, follow-cwd behavior, and restored session state. + +`PaneInfo` and `AgentInfo` expose optional `terminal_title` and `terminal_title_stripped` fields. `terminal_title` is the latest OSC 0/2 title after safety normalization. `terminal_title_stripped` removes one recognized leading activity or spinner glyph and following whitespace. These server-owned values are ephemeral across a cold restart and are independent of the metadata `title` and semantic agent state. + +Use `pane.report_metadata` when a user hook wants to customize presentation without taking over lifecycle state from a Herdr integration. + +```json +{ + "id": "req_2", + "method": "pane.report_metadata", + "params": { + "pane_id": "w1:p1", + "source": "user:claude-title", + "agent": "claude", + "title": "Refactor auth middleware", + "display_agent": "Claude: auth", + "state_labels": { + "working": "refactoring auth", + "idle": "ready", + "done": "review ready" + }, + "tokens": { + "summary": "refactor auth", + "model": "opus" + }, + "ttl_ms": 3600000 + } +} +``` + +Metadata reports are display-only. Valid metadata can override the pane title, displayed agent name, visible state labels, and arbitrary named tokens. `working`, `blocked`, `idle`, waits, notifications, and rollups still come from semantic state. Native session restore comes from stored official session references. `agent` is an optional guard for presentation fields against the authoritative agent label; `applies_to_source` similarly guards presentation fields against the active lifecycle authority source. These guards do not apply to token patches: token reporters own clearing and TTL refresh. Use `display_agent` to change the visible name. `state_labels` keys must be `idle`, `working`, `blocked`, `done`, or `unknown`. + +Token maps are per-resource patches. A string sets a key, JSON `null` clears it, and omitted keys remain unchanged. The latest accepted update wins. Optional TTL applies independently to token keys updated by that report. Pane tokens are exposed by pane and agent get/list responses and can be rendered as `$name` in Agent sidebar rows. A report may mention at most 16 token keys, and a pane or workspace may retain at most 32 keys. Token names are 1–32 ASCII letters, digits, underscores, or hyphens. + +Workspace tokens use the same contract: + +```json +{"id":"req_3","method":"workspace.report_metadata","params":{"workspace_id":"w1","source":"user:jj","tokens":{"jj_status":"2 changes","old":null},"ttl_ms":5000}} +``` + +Workspace get/list responses expose the resulting `tokens` map, and Space sidebar rows can render values such as `$jj_status`. Changes and TTL expiry emit `workspace.metadata_updated` with the latest workspace snapshot. This metadata event is available to API subscribers but does not invoke plugin event hooks. + +Presentation text is normalized before storage. Herdr trims surrounding whitespace, removes control characters, and caps `title`, `display_agent`, each state label, and token values at 80 characters. Empty normalized token values clear that key. + +`source` and `applies_to_source` are source identifiers. They must be 80 characters or fewer and may contain only ASCII letters, digits, colon, dot, underscore, and hyphen. + +Use `ttl_ms` for short-lived metadata. It must be between `1` and `86400000` milliseconds. Omit `ttl_ms` for metadata that should stay until replaced, cleared, or the pane/workspace closes. Presentation fields retain their existing source-scoped expiry behavior; each token updated by the call receives its own deadline. Token metadata is not restored after a server restart. + +Use `seq` when a hook may send updates out of order. For the same `source`, reports with a sequence number less than or equal to the last accepted sequence are accepted by the API but ignored by the pane state. A pane or workspace accepts sequenced token reports from at most 32 distinct sources during its lifetime; clearing or expiry does not release those source slots. + +## Event subscriptions + +Subscribe to events when you need a long-lived stream: + +```json +{ + "id": "sub_1", + "method": "events.subscribe", + "params": { + "subscriptions": [ + { "type": "pane.agent_status_changed", "pane_id": "w1:p1", "agent_status": "blocked" } + ] + } +} +``` + +The first response acknowledges the subscription. Later lines are pushed events. +Lifecycle subscriptions start when the request is accepted and do not replay +events retained before that point. + +Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.reordered`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.reordered` includes the atomically moved `workspace_ids`, optional `before_workspace_id`, and authoritative ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal. +Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`, +`tab.renamed`, and `tab.moved`. `tab.moved` includes the moved `tab_id`, +`workspace_id`, requested `insert_index`, and updated ordered `tabs` list for +that workspace. +Pane event subscriptions include `pane.created`, `pane.updated`, `pane.closed`, `pane.focused`, +`pane.moved`, `pane.exited`, `pane.agent_detected`, +`pane.output_matched`, `pane.agent_status_changed`, and `pane.scroll_changed`. +`pane.focused` also reports manual pane selection changes from any client attached +to this server. It does not move other clients' views, and its payload does not +identify the client. Selecting an already-selected pane does not emit it again. +Terminal-title changes can emit `pane.updated`, but spinner-only raw-title changes do not emit it when `terminal_title_stripped` is unchanged. +`pane.scroll_changed` is scoped to one `pane_id` and emits `pane_id`, +`workspace_id`, and the current `scroll` metrics whenever Herdr observes a +changed scroll snapshot. +Layout event subscriptions include `layout.updated`. The event carries the +updated `PaneLayoutSnapshot` for one tab. Clients that bootstrap with +`session.snapshot` should replace the cached layout with the same +`workspace_id` and `tab_id`. + +Worktree event subscriptions include `worktree.created`, `worktree.opened`, and `worktree.removed`. Worktree events describe Git checkout lifecycle. `worktree.created` includes the opened `workspace` and created `worktree`. `worktree.opened` includes the target `workspace`, opened `worktree`, and `already_open`. `worktree.removed` includes the `workspace_id`, removed `worktree`, and `forced`. + +Use `events.subscribe` for lifecycle events. Dedicated wait helpers are documented separately when a one-shot wait is supported. + +## Reading panes + +Use `pane.read` through the CLI unless you are writing a protocol client. + +```bash +herdr pane read w1:p1 --source visible --lines 80 +herdr pane read w1:p1 --source recent --lines 120 +herdr pane read w1:p1 --source recent-unwrapped --lines 120 +herdr pane read w1:p1 --source detection +``` + +`recent-unwrapped` is useful for logs because it ignores soft wrapping. +`detection` returns the bottom-buffer snapshot used by agent screen detection. + +## Waiting for state + +Use waits to coordinate agents and scripts. + +```bash +herdr agent wait w1:p1 --until done +herdr agent wait w1:p1 --until blocked +``` + +Agent waits observe semantic state, not arbitrary command completion. + +## Response shapes + +Successful responses look like this: + +```json +{ + "id": "req_1", + "result": { + "type": "pane_info", + "pane": { + "pane_id": "w1:p1", + "terminal_id": "term_abc123", + "workspace_id": "w1", + "tab_id": "w1:t1", + "focused": true, + "agent_status": "working", + "revision": 42 + } + } +} +``` + +`server.agent_manifests` returns the active agent detection manifest sources and remote update diagnostics without reloading rules: + +```json +{ + "id": "req_1", + "result": { + "type": "agent_manifest_status", + "last_check_unix": 1781043522, + "last_result": "checked", + "manifests": [ + { + "agent": "cursor", + "source": "/home/me/.config/herdr/agent-detection/cursor.toml", + "source_kind": "local override", + "active_version": "2026.06.10.1", + "cached_remote_version": "2026.06.10.1", + "local_override_shadowing_remote": true, + "remote_update_result": "current" + } + ] + } +} +``` + +Fields such as `last_check_unix`, `last_result`, `active_version`, `cached_remote_version`, `remote_update_result`, `remote_update_error`, `remote_last_checked_unix`, and `warning` are omitted when not available. `server.reload_agent_manifests` returns `agent_manifest_reload` with the same `manifests` item shape after reloading the in-memory rule cache. + +`agent.explain` evaluates the target pane's detection snapshot in the running server using the server's active manifest cache: + +```json +{ + "id": "req_2", + "method": "agent.explain", + "params": { "target": "w1:p1" } +} +``` + +The response contains the same explain object printed by `herdr agent explain --json`, including the final state, manifest source and version, matched rule, evaluated rule evidence, skip-state reason, idle fallback reason, and `screen_detection_skip_reason` when a full lifecycle hook authority makes screen rules non-authoritative. + +Clients need a running server that supports `agent.explain`; after upgrading Herdr, restart or live-handoff the server before relying on this method. + +Errors look like this: + +```json +{ + "id": "req_1", + "error": { + "code": "not_found", + "message": "pane not found" + } +} +``` + +## Protocol stability + +The client-rendered Herdr UI uses a stable endpoint generation for local and SSH +servers. Client and server builds do not need to match. During connection setup, +they agree on the core snapshot, screen, input, and blob codecs, and the server advertises +the API methods and optional capabilities it supports. Saved SSH federation requires the +`surface_interest` capability so only the selected machine streams a pane surface and the +`health_check` capability so a quiet broken connection cannot remain Online indefinitely. A server +without this lifecycle support stays at Attention until it is explicitly updated. Other missing methods disable +only those actions and show a client-local notice; they do not disconnect the UI. Rejected +or timed-out actions are also reported without ending the connection. Servers +from before endpoint generation 1 need one final update. + +The numbered binary protocol remains for same-install and internal operations, +including direct terminal attach and live handoff. Check `ping` or `herdr status` +before using those operations across different builds. JSON API clients should +ignore unknown fields and handle unsupported methods as normal errors. diff --git a/docs/versions/0.9.1/website/src/content/docs/troubleshooting.mdx b/docs/versions/0.9.1/website/src/content/docs/troubleshooting.mdx new file mode 100644 index 00000000..e7225893 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/troubleshooting.mdx @@ -0,0 +1,120 @@ +--- +title: Troubleshooting +description: Diagnose common installation, terminal input, session, keybinding, and remote access problems. +--- + +Start with the versions and session status: + +```bash +herdr -V +herdr status +``` + +Also record your operating system, outer terminal name and version, whether the session is local or remote, and whether tmux is involved. + +## The CJK IME window is misplaced or the cursor flickers on Windows + +Herdr defaults to a cell-drawn cursor on native Windows and WSL because native cursors can flicker, jump, or show stale positions while ConPTY-based multiplexers repaint the screen. A cell-drawn cursor cannot provide the native cursor anchor used by Korean, Japanese, and Chinese IME composition UI. + +If the IME composition or candidate window appears at the wrong location, enable the native cursor: + +```toml +[ui] +host_cursor = "native" +``` + +Native mode restores the IME anchor but may reintroduce occasional cursor movement artifacts during active output. Return to the visually stable cursor with `host_cursor = "drawn"`, or remove the setting to use the Windows default. See [Windows support](/docs/windows-beta/) for the current limitation. + +## Enter, Tab, or Backspace fires twice + +Older versions of some terminals can emit the release of Enter, Tab, and Backspace as the same bytes as the press when an application enables Kitty keyboard event reporting. Herdr cannot distinguish those duplicate bytes after the terminal sends them. + +Update the outer terminal to a version that contains its upstream fix: + +| Terminal | Minimum fixed version | +| --- | --- | +| kitty | 0.33.0 | +| foot | 1.20.0 | +| Alacritty | 0.15.0 | + +This is especially common with older terminal packages from long-term-support Linux distributions. See [Herdr issue #1116](https://github.com/herdrdev/herdr/issues/1116) for the confirmed boundary captures and upstream references. If the problem remains on a current terminal version, report the exact terminal version and whether it also happens outside Herdr. + +## Option+Left or Option+Right inserts `;3D` or `;3C` + +Terminals commonly send Alt+Left and Alt+Right as the standard modified-arrow sequences `ESC[1;3D` and `ESC[1;3C`. On macOS, the outer terminal must first be configured to treat Option as Alt. If the shell does not bind these sequences, zsh may display their remaining characters as `;3D` or `;3C`. This can happen with kitty, Alacritty, and other terminals; Herdr and tmux both preserve the original modified-arrow input. + +Add explicit zsh bindings if you want modified arrows to perform word navigation in every terminal and nested shell: + +```zsh +bindkey $'\e[1;3D' backward-word +bindkey $'\e[1;3C' forward-word +``` + +Kitty can appear to work differently outside Herdr because its automatic zsh integration adds these bindings only to shells started directly by kitty, not shells created by terminal multiplexers. Follow kitty's [manual shell integration instructions](https://sw.kovidgoyal.net/kitty/shell-integration/#manual-shell-integration), or map the keys in `kitty.conf` before they reach the shell: + +```text +map alt+left send_text all \x1bb +map alt+right send_text all \x1bf +``` + +Herdr deliberately does not rewrite modified arrows because terminal applications may use Alt+Left and Alt+Right directly. See [Herdr issue #1370](https://github.com/herdrdev/herdr/issues/1370) for the investigation. + +## Herdr updated, but the running session is still old + +Updating the binary does not replace a compatible server that is already running. Start Herdr again to use the updated client. If you also need server-side changes from the release, check `herdr status`, then stop the session and launch Herdr again: + +```bash +herdr server stop +herdr +``` + +Stopping a server exits its pane processes. Named sessions use `herdr session stop <name>`. See [Install Herdr](/docs/install/#update) for updater, package-manager, and live-handoff behavior. + +## Keychain-backed tools fail inside Herdr on macOS + +Check the server launch context from a Herdr pane: + +```bash +launchctl managername +``` + +`Background` is normal for automatically started macOS servers and does not by itself indicate a Keychain problem. If Keychain access fails and the server was started through SSH or a background job, stop it and start Herdr again from a normal GUI terminal: + +```bash +herdr server stop +herdr +``` + +Stopping the server exits its pane processes. Herdr panes inherit the long-lived server's macOS launch context, so a server started through SSH or a background job may not have access to interactive Keychain services. See [Herdr issue #966](https://github.com/herdrdev/herdr/issues/966) for details. + +## SSH or DNS fails after macOS logout + +Automatically started macOS servers use a per-user service context so logging out does not leave their panes with a dead connection to macOS services. Explicit `herdr server` launches retain their caller's context. + +An older server can survive logout while losing user lookup and DNS. Symptoms include `No user exists for uid 501`, `Could not get manager name.`, and `curl: (6) Could not resolve host`. Logging back in does not repair that server. Stop the affected session and start Herdr from a GUI terminal in the new login. This ends live pane processes but restores the saved session layout. Live handoff preserves the source server's service context and existing pane processes. Older servers therefore need a full session restart to adopt this fix; handoff cannot repair their inherited context. + +If a newly started server still has this problem, check `herdr-server.log` for `could not select persistent user service context`. Herdr keeps the inherited context if the required macOS APIs are unavailable. See [Herdr issue #4100](https://github.com/herdrdev/herdr/issues/4100). + +## The `herdr` command is not found + +Restart the terminal so it reloads its environment, then confirm the Herdr install directory is on `PATH`. For package-manager installs, use that package manager to update Herdr and expose it on `PATH`. See [Install Herdr](/docs/install/#verify). + +## A direct keybinding does nothing + +The operating system or outer terminal may consume the chord before Herdr receives it. Free the chord in that layer or choose another binding. See [Keyboard](/docs/keyboard/#going-prefix-free) for known conflicts and safe defaults. + +## Remote attach cannot authenticate + +First confirm that normal OpenSSH works with `ssh <host>`. For a passphrase-protected key in a non-interactive shell, CI job, or mobile terminal, load the key into `ssh-agent` before starting remote attach. See [Persistence and remote access](/docs/persistence-remote/#remote-attach-over-ssh). + +## Find diagnostic logs + +Herdr logs live in `~/.config/herdr/` by default: + +```text +herdr.log +herdr-client.log +herdr-server.log +``` + +Set `HERDR_LOG=herdr=debug` for more detail. Include the current log and rotated siblings when reporting a problem. See [Configuration](/docs/configuration/#logs). diff --git a/docs/versions/0.9.1/website/src/content/docs/windows-beta.mdx b/docs/versions/0.9.1/website/src/content/docs/windows-beta.mdx new file mode 100644 index 00000000..888080c8 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/windows-beta.mdx @@ -0,0 +1,147 @@ +--- +title: Windows support +description: Native Windows support, workflows, and known limitations. +--- + +Native Windows support is generally available. + +Herdr on Windows uses ConPTY and Windows process/runtime behavior instead of the Unix PTY model Herdr was originally built around. Most core workflows are supported, but some capabilities differ from Linux and macOS or remain platform-dependent. Windows may receive more platform-specific fixes as those remaining gaps close. + +Install Herdr natively on Windows with PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex" +``` + +If endpoint security blocks that fileless PowerShell command, open Command Prompt and run: + +```cmd +curl.exe -fsSLo install.cmd https://herdr.dev/install.cmd && install.cmd && del install.cmd +``` + +Windows builds are available through both stable and preview update channels. New installs use stable by default, and stable is recommended for normal use. Existing preview installs stay on preview until you run `herdr channel set stable`. If an older preview build rejects that command, run `herdr update` once on preview and retry. Preview provides newer, less-tested fixes from `master` and may regress; opt in with `herdr channel set preview` only when you want that tradeoff. + +The installer stores releases under `%USERPROFILE%\.herdr\packages\standalone\releases`, puts the active versioned release directory on PATH, keeps `%LOCALAPPDATA%\Programs\Herdr\bin` as a stable compatibility alias, and retains a small number of older releases so running processes do not block updates. + +For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom manifest instead of Herdr's stable or preview manifest. Set `HERDR_CHANNEL=preview` with a custom preview manifest. + +## Supported on Windows + +| Capability | Status | +| --- | --- | +| Local persistent sessions | supported | +| Native panes through ConPTY | supported | +| Windows Terminal / PowerShell app attach | supported | +| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; interactive attach and saved-machine setup can install or update Windows packages after confirmation; background reconnect only discovers installed packages | +| Remote clipboard images and image-file drops | supported | +| `cmd.exe` panes | supported | +| Native keyboard and mouse input | supported | +| Startup cwd and workspace labels | supported | +| Pane launch cwd | supported | +| Agent command discovery | supported | +| Supported agent self-report integrations | supported | +| Agent process-tree detection | supported | +| Git/worktree detection from known cwd | supported | +| System notifications and MP3 sounds | supported | +| Plugins | preview | +| Pane screen history | supported | +| Nested launch override | supported | + +Local persistent sessions continue running after the client detaches or its terminal window closes. Servers and pane processes launched through Windows OpenSSH also survive logout; run `herdr` again to reconnect. + +Windows agent process detection scans descendants of the pane shell and recognizes direct agents plus common command wrappers, including npm/Node and Git Bash process chains. It follows Git Bash-launched agents across emulated `exec` boundaries, but it is not the same as Unix foreground process-group detection. + +Windows integration installation currently supports Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, OpenCode, Kilo Code CLI, Droid, Kimi Code CLI, Qoder CLI, and Antigravity CLI. Availability is narrower than on Unix; Herdr hides or rejects integrations whose install format is not supported on Windows. + +Plugins support `windows` as a manifest platform in preview. GitHub install, local link, build commands, actions, events, and plugin panes are best-effort on Windows. Commands are argv commands and must be Windows-compatible; Node package shims such as `npm`, `bun`, and `node` are expected to work when they are on `PATH`, while Unix-only examples using `sh` or Bash need Windows-specific alternatives. Platform filters skip unsupported build commands and return `platform_unsupported` for unsupported actions or panes. + +## Partial support + +| Capability | Status | +| --- | --- | +| Live cwd after shell `cd` | partial | +| Live cwd via shell integration/OSC7 | supported | +| Clipboard image paste to agents in local panes | terminal/agent dependent | +| CJK IME composition anchoring | partial | +| Prefix input-source switching (Korean IME) | partial | +| Kitty graphics rendering | terminal dependent | +| Host cursor rendering | partial | + +Herdr can launch panes in the right directory and create the initial workspace from the directory where you started Herdr. After startup, the process field Herdr can inspect does not reliably track later logical `cd` changes in PowerShell. Use Herdr integrations or prompt shell integration for live cwd reporting. + +During `herdr --remote`, the configured remote image paste key reads a Windows clipboard image and transfers it to the remote host. Dropping one local image file into Windows Terminal also transfers that file and pastes its remote path. + +Prefix input-source switching is available as an opt-in experiment for the Korean IME. It switches Hangul input to English while prefix commands are active and restores the previous mode afterward: + +```toml +[experimental] +switch_ascii_input_source_in_prefix = true +``` + +Other Windows IMEs are not supported by this option yet. See [Configuration](/docs/configuration/#prefix-input-source-switching). + +Some Windows agents can receive `ctrl+v` and read clipboard images directly. Herdr's own clipboard-image reader is not wired into local native Windows panes, so agent-native image paste remains dependent on the terminal and agent. Agent image-paste shortcuts such as `alt+v` do not add a Herdr-managed local clipboard bridge. Remote clipboard image bridging is supported separately through `herdr --remote` to Linux, macOS, and Windows hosts. + +Kitty graphics is enabled by default and depends on the outer terminal. Herdr emits Kitty graphics protocol output on Windows as it does on other platforms. This path has been exercised with Windows WezTerm hosting Herdr through WSL, but native Windows terminal and ConPTY combinations are not all verified. Windows Terminal does not expose the Kitty graphics path Herdr uses. Set `[terminal].kitty_graphics = false` if the outer terminal mishandles graphics output. + +## Known caveats + +### Cursor rendering + +Herdr relies on ConPTY for native Windows panes. The Windows terminal cursor path can expose intermediate positions while a multiplexer repaints the screen. A native cursor may flicker, jump, or briefly remain at an old position during active output. This behavior also reproduces in other native Windows terminal multiplexers and with direct VT cursor-position stress tests, so Herdr cannot eliminate it while preserving native cursor behavior. + +To prioritize visual stability, the default `host_cursor = "auto"` draws Herdr's cursor as terminal cell content on native Windows and WSL. Other Linux and macOS clients continue to use the native terminal cursor. The drawn Windows cursor is steady and non-blinking, but it does not provide the outer terminal's native blink, shape, or cursor color. + +Windows does not use a drawn cursor to position IME composition and candidate UI. Korean, Japanese, or Chinese IME UI may therefore appear at the wrong location. If this affects you, opt back into the outer terminal cursor: + +```toml +[ui] +host_cursor = "native" +``` + +Native mode restores the IME anchor, but it can reintroduce occasional cursor flicker, jumps, or stale cursor positions during active output. + +### Keyboard and mouse + +Windows terminals do not all report modified keys in the same shape. Herdr preserves mouse reporting and `ctrl+j` in Windows Terminal and Alacritty on Windows. The native Windows input path also preserves physical key presses, repeats, releases, standalone Escape, and `shift+enter` through default ConPTY panes. Modified keys still depend on the outer terminal reporting a distinct key event; if it reports `shift+enter` as plain Enter, Herdr can only forward plain Enter. + +Windows packages include Microsoft's current app-local ConPTY runtime because the system ConPTY on older Windows 10 builds drops Kitty keyboard protocol sequences used by agents such as Kimi and Pi. Set `HERDR_WINDOWS_CONPTY=system` before starting Herdr only when diagnosing a compatibility problem with the bundled runtime. + +## Copy and paste + +Herdr's pane text copy works on Windows. Drag-select text inside a pane to copy through Herdr. + +For text paste, use `ctrl+shift+v` in Windows Terminal. Multiline text paste is bracketed so shells and agent prompts receive it as one paste instead of submitting each line separately. Hold `shift` and right-click to use the outer terminal paste action instead of sending the click through Herdr. + +## Not supported on Windows + +| Capability | Status | +| --- | --- | +| Direct terminal attach (`herdr terminal attach`) | unsupported | +| Live server handoff | unsupported | +| Unix file-descriptor handoff | unsupported | +| Unix foreground process groups | unsupported | +| Herdr clipboard image bridge in local native panes | unsupported | +| Signed binary / SmartScreen avoidance | unsupported | + +From Windows Terminal, use the same remote command as Linux and macOS: + +```powershell +herdr --remote workbox +``` + +The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. Interactive direct attach and saved-machine setup prompt before installing or updating the complete package when needed. Background saved reconnect only discovers installed packages and cannot prompt for installation or updates. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. + +Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release. Live handoff is Unix-only. + +## Reporting Windows issues + +Include: + +- Herdr version. +- Windows version. +- Terminal app. +- Shell, such as PowerShell or cmd. +- Whether you used a named `HERDR_SESSION`. +- Relevant Herdr logs. +- Exact steps to reproduce. diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-automation.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-automation.mdx new file mode 100644 index 00000000..8ad27952 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-automation.mdx @@ -0,0 +1,125 @@ +--- +title: 智能体自动化 +description: 使用 Herdr 的布局、窗格和智能体原语,从脚本或其他智能体协调编码智能体。 +--- + +Herdr 可以作为编码智能体的自动化层。脚本可以控制智能体,一个智能体也可以给其他智能体分配工作、检查状态并收集结果。关键是为任务选择正确的原语。 + +## 三种原语 + +| 原语 | 职责 | +| --- | --- | +| 布局(`workspace`、`tab` 和窗格拓扑) | 创建和组织终端位置。 | +| 窗格 | 控制原始终端:运行命令、发送输入、读取输出和等待输出。 | +| 智能体 | 按名称或窗格以及生命周期状态控制已识别的编码智能体。 | + +无论是否包含智能体,窗格都可以存在。智能体是 Herdr 在窗格中识别出的当前进程。因此,`agent start` 需要一个现有 shell 窗格,绝不会创建、拆分或移动布局。 + +创建工作区时也会创建第一个标签页和根窗格;创建标签页时会创建它的根窗格。第一个进程应使用返回的窗格 ID,只有布局需要另一个终端时才进行拆分。 + +创建命令输出 JSON。请从响应中读取 ID,不要猜测: + +```bash +created=$(herdr workspace create --cwd ~/project --label api --no-focus) +pane_id=$(printf '%s\n' "$created" | jq -r '.result.root_pane.pane_id') + +split=$(herdr pane split "$pane_id" --direction right --no-focus) +review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') +``` + +`workspace create` 返回 `.result.workspace`、`.result.tab` 和 `.result.root_pane`;`tab create` 返回 `.result.tab` 和 `.result.root_pane`;`pane split` 把新窗格返回为 `.result.pane`。 + +把窗格移动到另一个工作区会改变带工作区前缀的窗格 ID。执行 `pane move` 后,请继续使用 `.result.move_result.pane.pane_id`;旧值保留在 `.result.move_result.previous_pane_id`。运行中的进程会保留启动时的 Herdr 环境,但旧的 `HERDR_PANE_ID` 会继续作为该终端的别名,所以 `--current` 仍可安全使用。移动后发起的新命令仍可通过智能体名称解析,但已经进行中的等待会以 `agent_not_running` 结束。 + +对于 shell、测试、服务器、CI 监视器和其他普通终端进程,使用窗格命令。当 Herdr 需要理解正在运行的是哪个智能体,或它处于 `working`、`blocked`、`done`、`idle`、`unknown` 中的哪个状态时,使用智能体命令。 + +## 智能体身份与启动 + +`w1:p2` 这样的窗格 ID 标识终端位置。`reviewer` 这样的智能体名称是该窗格中当前智能体的便捷别名。名称必须匹配 `[a-z][a-z0-9_-]{0,31}`,并在实时智能体中唯一。智能体退出、被释放或被替换时,别名会被清除;它不会永久重命名窗格。 + +智能体命令既可以接受唯一的实时名称,也可以接受当前托管该智能体的窗格 ID。 + +可用的 shell 窗格必须停在交互式 shell 提示符,由 shell 自身占用前台,没有正在前台运行的命令、编辑器或智能体。调用 `agent start` 前先让窗格回到提示符。 + +`--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` 和 `muse`。`--` 后的参数会原样传给该可执行文件。 + +成功的 `agent start` 只有在 Herdr 于同一终端检测到预期智能体,并确认它可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认等待启动 30 秒;`--timeout` 必须大于 3000 且不超过 300000 毫秒。 + +```bash +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +``` + +手动启动的智能体也会被自动检测,可以用窗格 ID 指定。当需要一个稳定、易读的目标时,给它命名: + +```bash +herdr agent get w1:p2 +herdr agent rename w1:p2 reviewer +``` + +## 选择控制界面 + +| 目标 | 命令 | +| --- | --- | +| 运行并提交 shell 命令 | `pane run` | +| 发送不带 Enter 的纯文本 | `pane send-text` | +| 发送终端按键或修饰键组合 | `pane send-keys` | +| 等待文本或正则表达式 | `pane wait-output` | +| 在现有窗格中启动受支持的智能体 | `agent start` | +| 提交提示,并可选择等待 | `agent prompt` | +| 向智能体交互界面发送按键 | `agent send-keys` | +| 等待智能体生命周期状态 | `agent wait` | + +`agent prompt` 会先发送文本,短暂延迟后再发送编码后的 Enter,并遵循终端当前的 bracketed paste 模式。即使智能体正在 working 也可以提交。如果智能体已经是 `blocked`,它不会发送任何终端输入,而是返回 `agent_blocked`;请先检查对话框,再用 `agent send-keys` 明确作出响应。使用 `agent send-keys` 进行 `esc`、`up`、`enter`、`ctrl+c` 等交互;`escape` 也是 `esc` 的别名。只有在明确需要原始终端控制时才使用窗格输入命令。 + +窗格输入直接指定终端,不关心当前进程。智能体输入会解析实时智能体;如果该智能体已不再控制此窗格,操作会被拒绝。 + +如果智能体已经是 `blocked`,`agent prompt --wait` 会返回 `agent_blocked`,不会发送输入或开始等待。否则,它会将提示文本和延迟的 Enter 作为一次有序提交写入,然后等待状态。在 Windows 上,Codex 在 Enter 前会收到粘贴边界,因此提交不依赖提示大小;调用方的超时包含提交时间。如果提示从其他非 working 状态开始,提交后最多等待五秒来观察 `working` 或 `blocked`。如果没有观察到,Herdr 返回 `agent_prompt_stalled`;如果调用方的超时先到期,则返回普通的 `timeout` 错误。这可防止无关的 `idle`、`done` 或会话变化错误地完成等待。观察到活动后,它会等待请求的稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。独立的 `agent wait` 会观察当前智能体;如果状态已经匹配,就会立即返回。两者默认匹配 `idle`、`done` 或 `blocked`。可以重复使用 `--until` 接受多个精确状态,例如 `--until idle --until done`;需要 `unknown` 时请明确使用 `--until unknown`。在 `agent prompt` 中,`--until` 必须与 `--wait` 一起使用。 + +`idle` 和 `done` 都表示可以接受输入。CLI/API 使用服务器的已查看状态:`done` 表示尚未标记为已查看的 idle;显式 `pane focus` / `agent focus` 会标记目标,读取不会。每个 TUI 客户端独立记录它已显示的完成状态,因此其 Done 标记可能与 CLI 或其他客户端不同。`blocked` 表示 Herdr 识别到了审批或提问界面。`unknown` 表示智能体存在,但 Herdr 无法可靠判断其生命周期;它不代表工作成功完成。区别重要时,请指定精确的 `--until` 状态。 + +超时或 `agent_prompt_stalled` 并不意味着没有发送输入。重试前请先读取智能体,避免重复提交同一提示。ID 和智能体名称只在所属服务器内有效;在 TUI 中切换机器不会改变已有窗格中 CLI 命令的目标。 + +`pane wait-output` 不解释智能体生命周期。它轮询选定的终端快照并立即进行第一次搜索,所以已经存在的文本也会匹配。默认来源名是 `recent`;匹配时会把最近 80 个已渲染终端行作为未折行输出处理。`--lines` 可以修改这个行数限制;`--regex` 使用 Rust 正则表达式语法并逐行匹配。 + +在 CLI 中,`pane read` 和 `agent read` 都直接打印终端文本。默认输出去除 ANSI 转义的 UTF-8 文本;来源包含样式时,使用 `--format ansi` 或 `--ansi` 保留终端转义。`detection` 来源始终是纯文本。对 recent 类来源,`--lines N` 会在可选的取消折行之前选择最后 N 个已渲染终端行;省略时默认读取 80 行。对 `visible` 和 `detection`,省略 `--lines` 会返回完整快照,指定时保留按换行分隔的最后 N 行。socket API 在 `.result.read.text` 返回文本。 + +## 备用屏幕历史读取 + +Claude Code 和 OpenCode 等全屏智能体会把对话历史绘制在终端的备用屏幕中,而不是写入 Herdr 的主机回滚缓冲区。当已识别的智能体处于 idle、对话位于底部,且 `--lines` 请求的行数超过可见屏幕时,`recent` 或 `recent-unwrapped` 文本读取会自动使用智能体的鼠标滚动界面。Herdr 会收集相互重叠的页面,并在完成读取前把视口恢复到底部。当窗格中包含该智能体时,`pane read` 也会自动采用相同行为,无需额外选项。 + +其他读取仍保持被动。对于 `visible`、`detection` 或 ANSI 读取、输出等待和订阅、已被手动滚动的智能体、直接附加的窗格,以及不报告鼠标滚轮输入的应用,Herdr 不会移动应用视口。当显式的 `agent read --lines N` 需要备用屏幕历史时,如果智能体处于 working、blocked 或 unknown,命令会返回 `agent_not_idle`;请等待 idle 后重试,或使用 `--source visible`。其他 recent 读取仍像以前一样返回可用的屏幕和主机回滚内容。 + +如果仍无法取得完整回复,可以让智能体把回复以 Markdown 格式写入临时目录,只回复文件路径,然后直接读取该文件。 + +成功的 `agent start`、`agent prompt` 和 `agent wait` 会在 `.result.agent` 返回当前智能体。`pane wait-output` 返回 `.result.pane_id`、`.result.matched_line` 以及位于 `.result.read` 的匹配快照。 + +等待命令没有默认超时,可能无限等待。超时或服务器错误会把 JSON 错误写到 stderr 并以状态 1 退出;CLI 语法错误以状态 2 退出。 + +## 示例 + +启动辅助智能体,分配工作,并等待该工作结束: + +```bash +split=$(herdr pane split --current --direction right --no-focus) +review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +herdr agent prompt reviewer "Review the current diff" --wait --timeout 120000 +herdr agent read reviewer --source recent-unwrapped --lines 120 +``` + +等待智能体请求输入,检查内容,再操作其交互界面: + +```bash +herdr agent wait reviewer --until blocked --timeout 120000 +herdr agent read reviewer --source recent-unwrapped --lines 80 +herdr agent send-keys reviewer esc +``` + +运行普通进程并等待输出,而不把它当作智能体: + +```bash +herdr pane run w1:p3 "just test --watch" +herdr pane wait-output w1:p3 --regex "passed|failed" --timeout 120000 +``` + +完整命令和选项列表见 [CLI 参考](/zh-cn/docs/cli-reference/)。Shell 补全也能以交互方式显示同一命令树。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-skill.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-skill.mdx new file mode 100644 index 00000000..6ce065f4 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/agent-skill.mdx @@ -0,0 +1,67 @@ +--- +title: 智能体技能文件 +description: 为 Claude Code 或其他编程智能体安装 Herdr 使用说明。 +--- + +Herdr 提供了一个可复用的智能体技能文件: [`skills/herdr/SKILL.md`](https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md)。 + +把这个文件安装到任何支持可复用技能或自定义指令的编程智能体中。这份技能教智能体如何在 Herdr 窗格内部控制 Herdr。 + +Herdr 还提供了另一份用途不同的指南: [`herdr.dev/agent-guide.md`](https://herdr.dev/agent-guide.md),用于让智能体帮助人类学习、安装或排查 Herdr。技能是给操作 Herdr 的智能体用的;指南是给教人类的智能体用的。 + +## 这份技能做什么 + +这份技能告诉智能体: 当 `HERDR_ENV=1` 已设置时,使用 `herdr` CLI。这意味着智能体运行在 Herdr 管理的窗格内,可以安全地与本地 Herdr socket 通信。 + +安装技能后,智能体可以: + +- 查看工作区、标签页、窗格和相邻的智能体 +- 分割窗格并在不抢占焦点的情况下运行命令 +- 读取窗格输出和最近的日志 +- 等待服务器、测试或另一个智能体完成 +- 在相邻窗格中启动辅助智能体 + +这份技能不是独立的应用或服务,它只是一份给智能体看的 markdown 指令文件。如果已经安装 Herdr,运行 `herdr --skill` 即可输出与该二进制版本匹配的内置副本。 + +## 安装 + +用 `npx skills` 安装技能: + +```bash +npx skills add herdrdev/herdr --skill herdr -g +``` + +如果你是在该技能仍位于仓库根目录时安装的,请重新运行一次此 add 命令,而不要使用 `skills update`。add 命令会替换原先过大的副本并记录新位置。 + +`-g` 参数为受支持的智能体全局安装。省略 `-g` 则安装到当前项目。 + +以仓库中的副本作为手动兜底和事实来源: + +```text +https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md +``` + +对于有技能系统的智能体,把这个文件安装为名为 `herdr` 的技能。对于没有技能系统的智能体,把文件内容粘贴到智能体的项目指令或用户指令中。 + +安装完成后,在 Herdr 内启动智能体: + +```bash +herdr +claude +``` + +也可以在 Herdr 窗格里使用任何其他编程智能体。重点是智能体进程要运行在 Herdr 内部,这样 `HERDR_ENV=1` 才可用。 + +## 安全规则 + +这份技能以一条护栏开头: 如果 `HERDR_ENV=1` 未设置,智能体应当停下来,并说明自己没有运行在 Herdr 管理的窗格内。 + +这可以防止 Herdr 外部的智能体试图控制一个不属于它的会话。 + +## 面向智能体的参考 + +完整的命令指南就在技能文件本身。它涵盖窗格 ID、`pane split`、`pane run`、`pane read`、`pane wait-output`、`agent wait`、工作区和标签页命令,以及协作配方。 + +在这里阅读源文件: + +[在 GitHub 上打开 `SKILL.md` →](https://github.com/herdrdev/herdr/blob/master/skills/herdr/SKILL.md) diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/agents.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/agents.mdx new file mode 100644 index 00000000..bc749add --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/agents.mdx @@ -0,0 +1,159 @@ +--- +title: 智能体 +description: 了解 Herdr 能检测什么、智能体状态如何工作,以及集成如何改进它。 +--- + +Herdr 为同时运行多个编程智能体而生。每个智能体都待在一个真实的终端窗格里,shell、日志、提示符和运行中的进程都完好无损。Herdr 跟踪哪些窗格里有智能体,把它们的状态汇总到标签页和工作区,让你直接跳到需要关注的窗格,而不是手动轮询每个终端。 + +要从脚本或另一个智能体协调它们,请参阅[智能体自动化](/zh-cn/docs/agent-automation/)。 + +## 受支持的智能体 + +常见编程智能体开箱即用地支持自动检测。重要的区别不在于 Herdr 能不能看到某个智能体,而在于允许哪个信号来决定 `idle`、`working` 和 `blocked`。 + +| 智能体 | 状态权威 | 集成角色 | +| --- | --- | --- | +| Pi | 安装后为生命周期钩子;否则为屏幕清单 | 状态与会话 | +| OMP | 安装后为生命周期钩子 | 状态与会话 | +| GitHub Copilot CLI | 屏幕清单 | 会话 | +| Devin CLI | 屏幕清单 | 会话 | +| Kimi Code CLI | 安装后为生命周期钩子;否则为屏幕清单 | 状态与会话 | +| Hermes Agent | 屏幕清单 | 会话 | +| Qoder CLI | 屏幕清单 | 会话 | +| Qwen Code | 屏幕清单 | 会话 | +| Letta Code | 屏幕清单 | 会话 | +| Droid | 屏幕清单 | 会话 | +| OpenCode | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | +| Kilo Code CLI | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | +| MastraCode | 安装后为生命周期钩子 | 状态与会话 | +| Claude Code | 屏幕清单 | 会话 | +| Codex | 屏幕清单 | 会话 | +| Cursor Agent CLI | 屏幕清单 | 会话 | +| Amp | 屏幕清单 | 无 | +| Grok CLI | 屏幕清单 | 会话 | +| Antigravity CLI | 屏幕清单 | 会话 | +| Kiro CLI | 屏幕清单 | 无 | +| Maki | 屏幕清单 | 无 | +| Muse | 屏幕清单 | 无 | + +可检测但测试较少: Gemini CLI 和 Cline。不受支持的智能体仍然可以作为普通终端进程正常运行,只是在你添加集成或通过 socket API 上报状态之前,可能得不到丰富的状态。 + +## 状态权威 + +Herdr 首先检测每个窗格的前台进程。之后,每个窗格有且只有一个状态权威。 + +对于具备完整生命周期钩子的智能体,当集成已安装并在为运行中的窗格主动上报时,集成就是权威。Herdr 用这些钩子上报来决定 `idle`、`working`、`blocked` 和会话身份。对同一个生命周期权威,它不会再并行运行屏幕清单兜底。这样可以避免出现两个相互竞争的事实来源。 + +对于没有完整生命周期钩子的智能体,Herdr 识别前台进程,并读取实时的底部缓冲区屏幕快照。它对该快照评估 TOML 清单,来判定 `idle`、`working` 和 `blocked`。对于会发出这些信号的智能体,清单也可以把终端标题和进度 (OSC) 序列作为检测证据;当这些证据不存在时,屏幕规则独自承担检测。 + +屏幕快照来自窗格缓冲区最近的底部,而不是滚动后的视口。即使你在 Herdr 里向上翻页,检测仍然跟随底部的实时智能体 UI。 + +上表中标为“会话”的集成有意不作为生命周期权威。它们为恢复提供原生会话身份,但它们的钩子并不覆盖整个生命周期,可能漏掉权限审批结果、Esc 中断或其他状态转换。对这些智能体,Herdr 仍然使用屏幕清单检测。 + +## 虚拟机与沙箱包装器 + +在 Linux 和 macOS 上,宿主可见的包装器可能会向 Herdr 隐藏真实的智能体进程。在包装器命令上设置 `HERDR_AGENT=<agent>`,告诉 Herdr 应使用哪个已有智能体的屏幕清单。例如,在 Linux 上运行 `HERDR_AGENT=claude fence -- claude`,或在 macOS 上运行 `HERDR_AGENT=claude nono run --profile claude-code -- claude`。这个提示的作用范围只限于该前台进程;仅在 VM 或容器内部设置时 Herdr 无法看到它,而且除非所有继承的前台进程都应被视为该智能体,否则不要全局 export。 + +某些受限的 Linux 运行环境不会公开终端前台进程组。原生检测不可用时,以 `HERDR_PROCESS_DETECTION=child-groups` 启动 Herdr 服务器,即可选择启用直接子进程组推断。原生检测仍然优先,默认的 `native` 模式不会执行此推断。这个选择加入模式属于尽力而为,较新的后台任务可能会被误判为前台任务。该变量由服务器读取且需要重启;请在远程服务器环境中设置,而不是在连接客户端上设置。 + +## blocked 状态 + +对屏幕清单类智能体,blocked 检测刻意从严。只有当实时底部缓冲区快照匹配已知可见的审批、提问或权限 UI 时,Herdr 才标记 `blocked`。对已知智能体,如果没有任何清单规则匹配,Herdr 会回退到 `idle`,并在 explain 输出中把该回退标记为 `default_known_agent_idle_fallback`。 + +这意味着不常见的新智能体提示可能一开始显示为 `idle` 而不是 `blocked`,直到 Herdr 学会那种屏幕形态。这些交互不会让 Herdr 发送输入或执行破坏性操作;它们只影响可见状态和等待。 + +## 检测清单 + +内置清单打包在 Herdr 内部。Herdr 还会向 herdr.dev 检查远程清单更新,并自动应用有效的按智能体规则更新,不需要重启 Herdr。远程清单存放在 Herdr 的状态目录中。设置 `[update] manifest_check = false` 可以禁用后台远程清单检查。 + +本地覆盖可以从平台配置目录替换远程或内置清单: + +```text +~/.config/herdr/agent-detection/<agent>.toml +``` + +本地覆盖始终优先。没有本地覆盖时,Herdr 在缓存的远程清单和运行中二进制文件内置的清单之间,选择更新且兼容的那个。在调试构建上,同一个配置助手可能使用 `herdr-dev` 之类的开发目录。无效的覆盖文件会被忽略并给出警告,Herdr 会对该智能体回退到缓存的远程或内置清单。 + +远程清单只为 Herdr 已经知道如何识别的智能体修补检测规则。添加全新的智能体仍然需要更新 Herdr 二进制文件,以获得进程检测、标签和集成行为。 + +运行中的服务器在启动时把生效的清单加载进内存。自动的远程清单更新会在写入新规则后重新加载该内存缓存。运行 `herdr server update-agent-manifests` 可以立即拉取远程清单更新并重载运行中的服务器。手动编辑本地覆盖后,重启 Herdr 或运行 `herdr server reload-agent-manifests` 把文件应用到运行中的服务器。 + +当某个窗格显示了错误状态时,用 `herdr agent explain`: + +```bash +herdr agent explain <target> +herdr agent explain --file screen.txt --agent codex --json +``` + +实时 explain 由运行中的服务器评估,因此反映的是生效的清单缓存。explain 输出包括: 智能体、最终状态、屏幕检测是否被完整生命周期权威跳过、清单来源和版本、缓存的远程版本、本地覆盖的遮蔽情况、远程更新状态、匹配的规则、可见证据标志、已评估规则的匹配器和区域证据、转写查看器的跳过更新原因,以及没有规则匹配时的 idle 回退原因。 + +Herdr 可以在 tmux 作为外层终端环境时运行。智能体检测不会检查在 Herdr 窗格内启动的 tmux 会话。如果某个 shell 框架在 Herdr 内自动进入 tmux,Herdr 看到的窗格进程就是 `tmux`,而不是它背后的智能体。 + +## 状态汇总 + +侧边栏会把状态向上汇总。 + +一个 blocked 的智能体会让它的窗格、标签页和工作区看起来是 blocked。一个 working 的智能体会让工作区看起来处于活跃状态。一个 done 的智能体在你查看之前会一直保持可见。 + +这就是 Herdr 的主要工作流: 启动多个智能体,让它们并行工作,用侧边栏看哪个项目需要决策、哪个还在运行、哪个已经可以审阅。 + +## 直接集成 + +为你使用的每个智能体安装集成;它让 Herdr 获得钩子或插件上报,而不是只靠屏幕检测: + +```bash +herdr integration install claude +herdr integration status +``` + +每个受支持的智能体都有自己的集成名称和行为。按智能体的细节和完整安装列表见[集成](/zh-cn/docs/integrations/)。如果你正在构建智能体,[自定义集成指南](/zh-cn/docs/integrations/#集成你自己的智能体)介绍了如何在不为 Herdr 添加原生支持的情况下上报生命周期状态。 + +## 自定义智能体标签 + +你可以重命名智能体目标的显示名: + +```bash +herdr agent rename w1:p1 reviewer +herdr agent rename reviewer --clear +``` + +目标使用唯一的实时智能体名称,或当前承载该智能体的窗格 ID。终端 ID 和单独的智能体 kind 标签不能作为目标。 + +## 自定义状态标签 + +集成只把生命周期状态作为语义状态上报。展示自定义应通过窗格元数据令牌单独添加。 + +```bash +herdr pane report-agent w1:p1 \ + --source custom:indexer \ + --agent docs-bot \ + --state working + +herdr pane report-metadata w1:p1 \ + --source custom:indexer-display \ + --token summary=indexing +``` + +`state` 控制等待、通知和汇总。`summary` 令牌只影响展示,可在智能体侧边栏行中写成 `$summary`。 + +智能体侧边栏行也可以选择使用 `terminal_title` 或 `terminal_title_stripped`;两者都不在默认行中。前者显示经过安全规范化的最新 OSC 0/2 终端标题,后者会移除开头一个已识别的活动或旋转指示符字形及其后的空白。这些值由 Herdr 服务器所有,冷重启后不会保留,并且独立于元数据标题和语义智能体状态。因此,如果移除后的文本不变,旋转动画可以改变原始标题而不触发窗格更新。 + +## 直接附加到智能体 + +把当前终端附加到某一个智能体终端,而不是完整的 Herdr UI: + +```bash +herdr agent attach reviewer +``` + +用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 + +用鼠标滚轮或普通的 page up/page down 滚动。正常输入会跳回底部。 + +如果另一个直接附加客户端已经拥有输入,用 `--takeover`: + +```bash +herdr agent attach reviewer --takeover +``` + +想对非智能体终端获得相同的直接附加行为时,用 `herdr terminal attach <terminal_id>`。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/cli-reference.mdx new file mode 100644 index 00000000..4c7df02a --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/cli-reference.mdx @@ -0,0 +1,495 @@ +--- +title: CLI 参考 +description: 用于会话、工作区、标签页、窗格、通知、智能体、等待、集成和状态的 Herdr 命令。 +--- + +Herdr 的 CLI 通过与集成和智能体相同的本地 socket API 与运行中的服务器通信。 + +大多数命令输出 JSON 响应。需要确定性自动化时,从脚本中使用它们。 + +## 启动与状态 + +```bash +herdr # 启动或连接默认会话 +herdr --remote workbox # 通过 SSH 连接,使用本地按键绑定 +herdr --default-config # 打印默认配置 +herdr update # 从配置的通道下载并安装 +herdr completion zsh # 生成 zsh 补全脚本 +herdr channel show # 打印 stable 或 preview +herdr channel set preview # 启用预览构建 +herdr channel set stable # 把直接安装切回稳定版 +herdr --version # 打印版本 +``` + +可选的启动和更新设置: + +- 需要命名会话而非默认会话时,添加 `--session <name>`。 +- 远程连接要使用服务器按键绑定而非本地绑定时,添加 `--remote-keybindings server`。 +- 实验性实时交接需要在 `herdr --remote` 或 `herdr update` 中明确添加 `--handoff`,不是正常设置或连接流程;参见[更新](/zh-cn/docs/install/)。 + +状态命令: + +```bash +herdr status +herdr status server +herdr status client +``` + +API schema 命令: + +```bash +herdr api schema +herdr api schema --json +herdr api schema --output herdr-api.schema.json +``` + +`herdr api schema` 会打印安装的二进制中包含的 socket 协议 schema 简短摘要。需要完整 JSON Schema 文档时使用 `--json`;要写入文件则使用 `--output PATH`。 + +## 已保存的 SSH 机器 + +```bash +herdr machine list +herdr machine add workbox --label "Build machine" +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +`machine add` 默认使用远程默认会话。只有需要命名会话时才添加 `--remote-session <name>`。`machine list` 支持可选的 `--json` 供脚本使用。完整流程见[连接机器](/zh-cn/docs/connecting-machines/)。 + +`machine add` 会检查远程 Herdr 是否具备所需能力,仅在必要时征得批准后安装或更新,并在保存配置前启动指定会话的后台服务器。只要兼容,发行版本不必一致。需要批准安装或重启时,请在交互式终端中运行;设置失败或取消时不会保存配置。替换运行中的服务器需要明确批准,默认回答为 No;停止服务器会结束其窗格进程。`machine add` 不会隐式启用实验性实时交接。 + +更改会自动应用到已打开的本地 Herdr 客户端,通常在一秒内生效。新增或启用的机器在后台连接;重命名不会重新连接。移除或禁用只会断开该机器,远程会话仍会继续运行。移除正在查看的机器会返回 Local;如果 Local 不可用,则显示不可用状态,直到重新连接。每份配置在客户端状态文件中保存一个不透明 ID、标签、SSH 目标、明确的远程会话名和启用状态。Herdr 不保存密码、私钥或其他 SSH 凭据。 + +自动连接和重连均为非交互式。如果主机密钥、密码、密钥口令、MFA、安装、更新或重启需要批准,机器会显示 Attention,而不会打开隐藏的提示。请运行 Herdr 显示的独立连接命令,例如 `herdr --remote workbox`,在前台完成设置后重启客户端。仅当配置指向命名会话时,才加上 `--session <name>`。 + +工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。UI 中的选择不会改变 CLI 的目标。使用全局**前缀** `--machine <label-or-id>` 将 API 命令发送到已保存的 SSH 机器: + +```bash +herdr --machine "Build machine" agent list +herdr --machine <profile-id> pane list +herdr --machine "Build machine" agent prompt w1:p1 "review this change" +herdr --machine "Build machine" worktree create --cwd /srv/project --branch review +``` + +选择器必须是已启用的配置 ID 或唯一标签(区分大小写),不能是任意 SSH 主机名。命令使用保存的远程会话,不能与 `--session` 或 `--remote` 混用。省略前缀时,本地行为保持不变。 + +支持 `workspace`、`worktree`、`tab`、`pane`、`notification`、`agent`(不含 `attach` 和本地 `explain --file`)、`api snapshot`、`status server`,以及基于 API 的插件操作(`link`、`unlink`、`enable`、`disable`、`list`、`action`、`log`、`pane`)。服务器操作支持 `stop`、`reload-config`、`agent-manifests`、`reload-agent-manifests`。本地安装和配置、插件安装、会话管理及交互式终端连接不会被转发。 + +请求和响应通过非交互式 SSH 传输 JSON API,API 内容不会被拼接到 SSH shell 命令中。不需要打开 TUI,也不会自动安装、启动或重启服务器。请更新本地 CLI 和远程 Herdr,并确保运行中的服务器 API 协议兼容。认证、连接或版本错误不会回退到 Local,连接失败后也不会自动重试请求。 + +远程命令不会继承调用方的本地窗格 ID,请显式指定远程 ID。`--current` 不能引用本地窗格。远程 worktree 路径必须为绝对路径、`~` 或以 `~/` 开头(由服务器展开);插件 link 路径必须为绝对路径。每次只操作一台机器,不包含合并列表或通过其他 TUI 连接转发的功能。 + +## Shell 补全 + +```bash +herdr completion zsh +herdr completions zsh +herdr completion bash +herdr completion fish +herdr completion powershell +herdr completion elvish +``` + +`completion` 会把脚本打印到 stdout。`completions` 是别名。临时 zsh 会话可以直接加载脚本: + +```bash +source <(herdr completion zsh) +``` + +持久 zsh 设置中,把生成的 `_herdr` 函数写到 `compinit` 运行前已经在 `fpath` 上的位置: + +```bash +mkdir -p ~/.zfunc +herdr completion zsh > ~/.zfunc/_herdr +``` + +然后确认 `.zshrc` 包含: + +```zsh +fpath=(~/.zfunc $fpath) +autoload -Uz compinit +compinit +``` + +## 服务器 + +```bash +herdr server +herdr server stop +herdr server reload-config +herdr server agent-manifests [--json] +herdr server update-agent-manifests [--json] +herdr server reload-agent-manifests +``` + +`herdr server` 显式运行无界面服务器,适合被监管或服务式的部署。`reload-config` 在不重启窗格的情况下应用可重载设置。`agent-manifests` 显示生效的智能体检测清单来源、缓存的远程版本和最近的远程更新结果。`update-agent-manifests` 立即拉取远程清单更新,重载到运行中的服务器,并打印更新后的清单状态;要原始状态响应就加 `--json`。`reload-agent-manifests` 在编辑本地覆盖后,把智能体检测清单重载到运行中的服务器。 + +## 通知 + +```bash +herdr notification show <title> [--body TEXT] [--position top-left|top-right|bottom-left|bottom-right] [--sound none|done|request] +``` + +`notification show` 使用配置的 `[ui.toast]` 投递方式。`--position` 只影响 Herdr 应用内的 toast。`--sound` 默认为 `none`;`done` 和 `request` 只在通知实际显示时播放已有的完成音和需要关注音。 + +## 会话 + +```bash +herdr session list [--json] +herdr session attach <name> +herdr session stop <name> [--json] +herdr session delete <name> [--json] +``` + +需要显式停止默认会话时,把会话名写成 `default`。 + +## 工作区 + +```bash +herdr workspace list +herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus] +herdr workspace get <workspace_id> +herdr workspace focus <workspace_id> +herdr workspace rename <workspace_id> <label> +herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N] +herdr workspace close <workspace_id> [--group] +``` + +不抢占焦点地创建工作区: + +```bash +herdr workspace create --cwd ~/project --label api --no-focus +``` + +工作区是顶层项目或工作上下文。创建工作区也会创建第一个标签页和根窗格。JSON 响应中的 `.result.workspace.workspace_id`、`.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。 + +## Worktree + +```bash +herdr worktree list [--workspace ID | --cwd PATH] [--trust-repository] +herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--trust-repository] +herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository] +herdr worktree remove --workspace ID [--force] [--trust-repository] +``` + +worktree 是带有 Git 检出来源信息的普通 Herdr 工作区。`worktree create` 创建一个 Git worktree 检出,作为工作区打开,并与父仓库工作区分到一组。如果 `--branch` 指向已有的本地分支,Herdr 检出它;否则从 `--base` 或 `HEAD` 创建分支。没有 `--path` 时,Herdr 在 `<worktrees.directory>/<repo>/<branch-slug>` 下创建检出。 + +`workspace close` 只关闭 Herdr 状态。当关联的 worktree 工作区仍然打开时,关闭父工作区需要 `--group`;否则命令会保留整个组并返回 `workspace_group_close_required`。`worktree remove` 是显式的检出删除路径;它运行 `git worktree remove`,从不删除分支,并在 Git 拒绝脏检出时要求 `--force`。 + +Git 默认拒绝由其他用户拥有的仓库。如果你已独立验证该仓库,可传入 `--trust-repository`,仅在本次命令中信任解析后的仓库路径。Herdr 不会修改你的 Git 配置。 + +## 标签页 + +```bash +herdr tab list [--workspace <workspace_id>] +herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus] +herdr tab get <tab_id> +herdr tab focus <tab_id> +herdr tab rename <tab_id> <label> +herdr tab close <tab_id> +``` + +标签页是工作区内的另一个终端布局。不指定 `--workspace` 时,`tab create` 使用活动工作区;如果没有活动工作区则失败。JSON 响应中的 `.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。 + +创建工作区、标签页和拆分窗格默认不改变焦点。`--focus` 选择新布局;`--no-focus` 显式表达默认行为。不指定 `--cwd` 时,新终端遵循 `terminal.new_cwd` 设置,默认跟随来源窗格或工作区。每个 `--env KEY=VALUE` 在新的根 shell 中添加或替换一个环境变量。 + +## 窗格 + +```bash +herdr pane list [--workspace <workspace_id>] +herdr pane current [--pane ID|--current] +herdr pane get <pane_id> +herdr pane layout [--pane ID|--current] +herdr pane process-info [--pane ID|--current] +herdr pane neighbor --direction left|right|up|down [--pane ID|--current] +herdr pane edges [--pane ID|--current] +herdr pane focus --direction left|right|up|down [--pane ID|--current] +herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current] +herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off] +herdr pane rename <pane_id> <label>|--clear +herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus] +herdr pane swap --direction left|right|up|down [--pane ID|--current] +herdr pane swap --source-pane ID --target-pane ID +herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus] +herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus] +herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus] +herdr pane close <pane_id> +``` + +接受 `--current` 的窗格命令在 Herdr 窗格内运行时使用调用方的 `HERDR_PANE_ID`。对 `pane split` 而言,显式窗格 ID 或 `--pane ID` 拆分指定窗格。省略目标时,若存在 `HERDR_PANE_ID` 则拆分调用方窗格,否则使用当前聚焦的窗格。`--current` 必须有调用方窗格;缺少 `HERDR_PANE_ID` 时会报错。拆分响应中的新窗格 ID 位于 `.result.pane.pane_id`。 + +执行 `pane move` 后,后续命令应使用 `.result.move_result.pane.pane_id`。跨工作区移动会改变带工作区前缀的窗格 ID;旧值保留在 `.result.move_result.previous_pane_id`。运行中进程的 `HERDR_PANE_ID`、`HERDR_TAB_ID` 和 `HERDR_WORKSPACE_ID` 保持启动时的值,但 Herdr 会把旧窗格 ID 保留为该终端的别名,所以使用 `--current` 的窗格命令仍能解析它。实时智能体名称也会跟随终端。 + +读取输出: + +```bash +herdr pane read <pane_id> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] [--raw] +herdr pane read <pane_id> --source visible --ansi +herdr pane read <pane_id> --source recent-unwrapped --lines 120 +``` + +`pane read` 直接打印 UTF-8 终端文本。默认去除 ANSI 转义;来源包含样式时,使用 `--format ansi` 或 `--ansi` 保留。`detection` 来源始终是纯文本。对 recent 类来源,`--lines N` 会在可选的取消折行之前选择最后 N 个已渲染终端行;省略时默认读取 80 行。对 `visible` 和 `detection`,省略 `--lines` 会返回完整快照,指定时保留按换行分隔的最后 N 行。`agent read` 使用相同的输出和行数行为。 + +发送输入: + +```bash +herdr pane send-text <pane_id> <text> +herdr pane send-keys <pane_id> <key> [key ...] +herdr pane run <pane_id> <command> +``` + +`<key>` 使用 Herdr 的组合键语法: `a` 这类普通可打印键,`enter`、`tab`、`esc`、`backspace`、`left`、`right`、`up`、`down` 这类特殊键,`ctrl+h`、`control+j`、`alt+x`、`shift+tab` 这类修饰组合键,`f1` 这类功能键,以及 `minus`、`plus`、`backtick` 这类命名标点。旧式的 `C-c` 和 `c-c` 作为 `ctrl+c` 的别名被接受。 + +标准拼写是 `esc`;也接受 `escape`。 + +`pane run` 会遵循当前的 bracketed paste 模式,并把文本和 Enter 作为一个原子操作提交。发送命令时优先用它,而不是 `send-text` 加 `send-keys Enter`;单独的发送操作仍是底层操作,不会提交命令。 + +从自定义钩子上报智能体状态: + +```bash +herdr pane report-agent <pane_id> \ + --source ID \ + --agent LABEL \ + --state idle|working|blocked|unknown \ + [--message TEXT] \ + [--seq N] \ + [--agent-session-id ID] \ + [--agent-session-path PATH] + +herdr pane report-agent-session <pane_id> \ + --source ID \ + --agent LABEL \ + [--seq N] \ + [--agent-session-id ID] \ + [--agent-session-path PATH] \ + [--session-start-source SOURCE] + +herdr pane release-agent <pane_id> \ + --source ID \ + --agent LABEL \ + [--seq N] +``` + +`report-agent-session` 只更新原生会话身份,不上报生命周期状态。`release-agent` 在智能体进程退出时结束该来源的生命周期权威。 + +当官方集成上报了原生会话引用时,`pane get`、`pane list`、`agent get` 和 `agent list` 会包含一个只读的 `agent_session` 对象。没有存储原生会话引用时,该字段被省略。 + +当 Herdr 能解析控制窗格的前台进程的 cwd 时,这些命令会包含 `foreground_cwd`。已有的 `cwd` 字段仍然是用于标签和 follow-cwd 行为的窗格/工作区 cwd。 + +上报仅用于展示的窗格元数据,而不接管语义状态: + +```bash +herdr pane report-metadata <pane_id> \ + --source ID \ + [--agent LABEL] \ + [--applies-to-source ID] \ + [--title TEXT|--clear-title] \ + [--display-agent TEXT|--clear-display-agent] \ + [--state-label STATUS=TEXT] \ + [--clear-state-labels] \ + [--token NAME=VALUE] \ + [--clear-token NAME] \ + [--seq N] \ + [--ttl-ms N] +``` + +`STATUS` 是 `idle`、`working`、`blocked`、`done` 或 `unknown` 之一。`--agent` 和 `--applies-to-source` 只守卫 `--title`、`--display-agent` 和 `--state-label`,不守卫令牌补丁。上报方负责清除令牌或刷新 TTL。用 `--display-agent` 修改可见名称。 + +元数据文本在存储前会被规范化。Herdr 去掉首尾空白、移除控制字符,并把 `--title`、`--display-agent`、每个 `--state-label` 和令牌值截断到 80 个字符。规范化后为空的令牌值会清除该键。 + +`--token` 设置一个命名展示值,`--clear-token` 删除一个值。未提及的令牌保持不变。窗格令牌可在智能体侧边栏行中写成 `$name`;工作区令牌可用于空间行。TTL 分别应用于该次调用更新的每个令牌键。 + +`--source` 和 `--applies-to-source` 必须不超过 80 个字符,并且只能包含 ASCII 字母、数字、冒号、点、下划线和连字符。`--ttl-ms` 让元数据自动过期,取值必须在 `1` 到 `86400000` 毫秒之间。想让元数据一直保留到被替换、清除或窗格或工作区关闭时,省略它。`--seq` 让 Herdr 忽略来自同一 `--source` 的过期上报;过期上报会被 API 接受,但被窗格状态忽略。每个窗格或工作区在其生命周期内最多接受来自 32 个不同来源的带序号令牌上报,清除或过期不会释放这些来源名额。 + +## 智能体 + +窗格与智能体的模型和完整协调示例见[智能体自动化](/zh-cn/docs/agent-automation/)。 + +```bash +herdr agent list +herdr agent get <target> +herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] +herdr agent send-keys <target> <key> [key ...] +herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS] +herdr agent rename <target> <name>|--clear +herdr agent focus <target> +herdr agent wait <target> [--until STATUS]... [--timeout MS] +herdr agent attach <target> [--takeover] +herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>] +herdr agent explain <target> [--json|--verbose] +herdr agent explain --file PATH --agent LABEL [--json|--verbose] +``` + +智能体目标只能是唯一的实时智能体名称,或当前承载该智能体的窗格 ID。终端 ID 和单独的智能体 kind 标签不能作为目标。通过 `agent start` 启动的智能体必须有名称;手动启动的智能体保持未命名,通过窗格 ID 寻址。 + +`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`letta`、`maki` 和 `muse`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 + +成功的 start 只有在预期智能体占用同一终端并可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 + +`agent prompt` 遵循当前的 bracketed paste 模式,即使智能体处于 working,也会将文本和延迟的 Enter 作为一次有序提交写入。不带 `--wait` 时,成功只确认写入完成,不代表轮次已经开始。在 Windows 上,Codex 在 Enter 前会收到粘贴边界,因此提交不依赖提示大小;调用方的超时包含提交时间。如果智能体已经是 `blocked`,它不会发送输入,而是返回 `agent_blocked`。使用 `--wait` 时,从其他非 working 状态接受的提示必须在提交后的最多五秒内产生可观察的 `working` 或 `blocked` 状态,否则 Herdr 返回 `agent_prompt_stalled`;如果调用方的超时先到期,则返回普通的 `timeout` 错误。这可防止无关的 `idle`、`done` 或会话变化错误地完成等待。观察到活动后,它会等待请求的第一个稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。`--until` 用于缩小匹配状态,不带 `--wait` 时会被拒绝。独立的 `agent wait` 在当前状态匹配时立即返回。两者默认匹配 `idle`、`done` 或 `blocked`;需要 `unknown` 时请明确使用 `--until unknown`。 + +`idle` 和 `done` 都表示可以接受输入。CLI/API 使用服务器的已查看状态:`done` 表示尚未标记为已查看的 idle;显式 `pane focus` / `agent focus` 会标记目标,读取不会。每个 TUI 客户端独立记录它已显示的完成状态,因此其 Done 标记可能与 CLI 或其他客户端不同。`blocked` 表示 Herdr 识别到审批或提问界面。`unknown` 表示智能体存在但无法可靠分类,不代表工作成功。 + +`agent send-keys` 发送 `enter`、`up`、`esc`、`ctrl+c` 等逻辑按键。Herdr 会在写入任何字节前验证所有按键。`agent read` 读取解析出的终端流,`agent rename` 为已检测到的智能体命名。 + +`agent explain` 请求运行中的服务器对屏幕检测所用的同一份底部缓冲区检测快照进行分类,因此实时输出反映服务器生效的清单缓存。因为它使用 `agent.explain` socket 方法,升级 Herdr 后,请先重启或交接到更新后的服务器,再使用实时 explain。用 `--file PATH --agent LABEL` 可以改为在本地解释一份保存的样本。默认输出显示智能体、最终状态、清单来源和版本、匹配的规则及其区域证据,以及任何回退、跳过或警告原因。加 `--verbose` 可以看到可见证据标志、缓存的远程版本、本地覆盖的遮蔽情况、远程更新状态,以及带匹配器和区域证据的完整已评估规则列表。提交问题报告或写测试时加 `--json`。 + +普通终端、服务器、测试、shell 或底层终端控制,请使用 `pane send-text`、`pane send-keys`、`pane run` 和 `terminal attach`。想带回车提交命令时用 `pane run`。 + +## 直接终端附加 + +```bash +herdr terminal attach <terminal_id> [--takeover] +herdr terminal session control <target> [--takeover] [--cols N] [--rows N] +herdr terminal session observe <target> [--cols N] [--rows N] +herdr terminal title set <title> +herdr terminal title clear +``` + +从直接附加中用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 +`terminal session control` 为窗格、终端或智能体打开可写的实时终端流,输出与 observe 相同的逐行 JSON `terminal.frame` 和 `terminal.closed`,并从标准输入读取 `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` 命令。同一终端同时只能有一个控制方,可用 `--takeover` 接管。 + +`terminal session observe` 为相同目标打开只读实时流,输出包含 base64 ANSI 字节的逐行 JSON `terminal.frame`,服务器关闭流时输出 `terminal.closed`。多个观察方可以同时查看终端,不获取输入、调整尺寸、滚动或接管权限。Linux/macOS 上,若套接字写入连续 30 秒没有进展,观察方会被断开;此时可能没有最后的 `terminal.closed` 记录,但窗格和其他客户端继续运行。安静、没有输出的窗格不会触发此超时。 + +`terminal title clear` 把外层终端窗口标题交还给 `ui.window_title`。 + +## 输出等待 + +等待窗格中的输出: + +```bash +herdr pane wait-output <pane_id> (--match <text> | --regex <pattern>) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw] +``` + +普通命令和服务器用 `pane wait-output`。编程智能体用 `agent wait`。 + +`pane wait-output` 会立即检查所选快照,所以已经存在的输出也能匹配。默认来源名是 `recent`;匹配时会把最近 80 个已渲染终端行作为未折行输出处理。`--lines` 可以修改这个行数限制。`--match` 在单行中查找字面子串;`--regex` 使用 Rust 正则表达式语法并逐行匹配。 + +超时或 `agent_prompt_stalled` 并不代表未发送提示。重试前请先检查智能体。 + +`pane wait-output` 和 `agent wait` 在省略 `--timeout` 时会无限等待。对于 `agent prompt --wait`,观察到活动后,或提示开始时已处于 `working`,等待稳定状态的过程不会超时;如果提示从 non-working 状态开始且未观察到活动,五秒后仍会返回 `agent_prompt_stalled`。超时或服务器错误以 JSON 写到 stderr,退出状态为 1。CLI 用法错误的退出状态为 2。 + +## 集成 + +```bash +herdr integration install pi +herdr integration install omp +herdr integration install claude +herdr integration install codex +herdr integration install copilot +herdr integration install devin +herdr integration install droid +herdr integration install kimi +herdr integration install opencode +herdr integration install kilo +herdr integration install hermes +herdr integration install mastracode +herdr integration install qodercli +herdr integration install qwen +herdr integration install letta +herdr integration install cursor +herdr integration uninstall pi +herdr integration uninstall omp +herdr integration uninstall claude +herdr integration uninstall codex +herdr integration uninstall copilot +herdr integration uninstall devin +herdr integration uninstall droid +herdr integration uninstall kimi +herdr integration uninstall opencode +herdr integration uninstall kilo +herdr integration uninstall hermes +herdr integration uninstall mastracode +herdr integration uninstall qodercli +herdr integration uninstall qwen +herdr integration uninstall letta +herdr integration uninstall cursor +herdr integration status [--outdated-only] +``` + +## 插件 + +插件命令用于安装和运行本地可执行的工作流插件。插件是清单加进程外命令;Herdr 负责宿主侧,插件负责自己的实现语言。 + +安装、列出和移除插件: + +```bash +herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes] +herdr plugin list [--plugin ID] [--json] +herdr plugin uninstall <plugin_id|owner/repo[/subdir...]> +herdr plugin enable <plugin_id> +herdr plugin disable <plugin_id> +``` + +`plugin install` 只接受 GitHub 简写,比如 `ogulcancelik/herdr-plugin-examples/worktree-bootstrap`。它使用 `git`,在交互式终端显示信任预览,运行受支持的清单构建命令,并把 GitHub 安装保存在 Herdr 管理的目录中。非交互式安装用 `--yes`。重新安装 GitHub 管理的插件会替换该托管检出。不允许覆盖安装到本地链接的插件之上。插件清单必须声明 `min_herdr_version`;当插件要求更新的 Herdr 二进制时,install 和 link 会失败。`plugin list` 默认是人类可读的;要原始 API 响应就传 `--json`。 + +本地开发: + +```bash +herdr plugin link <path> [--disabled] +herdr plugin unlink <plugin_id> +``` + +`plugin link` 接受包含 `herdr-plugin.toml` 的插件目录,或直接指向清单的路径。从本地检出编写或测试插件时,它仍然是正确的命令。`plugin unlink` 注销插件、不动文件。`plugin uninstall` 注销插件,并同时删除 Herdr 管理的 GitHub 检出文件。对 GitHub 安装,uninstall 既接受插件 id,也接受与 install 相同的 `owner/repo[/subdir...]` 简写。动作、事件钩子、窗格和链接处理器在清单中声明;运行时动作注册不在 v1 范围内。 + +配置目录: + +```bash +herdr plugin config-dir <plugin_id> +``` + +`plugin config-dir` 打印插件的配置目录,需要时会创建它 (旧版插件配置位置存在时会从那里初始化)。在安装文档和 shell 脚本中用它给用户指出一个稳定路径,用于存放 `.env` 等用户可编辑配置,与托管的插件检出分开。 + +动作: + +```bash +herdr plugin action list [--plugin ID] +herdr plugin action invoke <action_id> [--plugin ID] +``` + +`plugin action invoke` 为一个已安装、已启用、平台兼容的插件动作启动清单命令,并在 JSON 响应中打印已启动命令的日志记录。当多个插件使用相同的动作 id 时,使用限定的动作 id (`plugin.id.action`)。本地动作 id 不能包含点,所以即使插件 id 包含点,限定 id 也不会有歧义。 + +日志: + +```bash +herdr plugin log list [--plugin ID] [--limit N] +``` + +托管终端窗格: + +```bash +herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|popup|split|tab|zoomed] [--width SIZE] [--height SIZE] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus] +herdr plugin pane focus <pane_id> +herdr plugin pane close <pane_id> +``` + +`plugin pane open` 要求插件已链接、已启用并与当前平台兼容。它把清单声明的 `[[panes]]` 命令作为 Herdr 管理的终端窗格启动。清单的默认值是 `overlay`,在活动窗格上方打开一个临时的缩放覆盖层。它也可以作为分割、新标签页、缩放窗格,或不改变标签页布局的会话级模态 `popup` 打开。`--width` 和 `--height` 以终端单元格数或 `80%` 这样的百分比设置弹窗外层尺寸;省略时默认为终端大小的一半,过小的值会限制为弹窗最小尺寸。弹窗不是 Herdr 窗格,不会收到 `HERDR_PANE_ID`,也不参与 pane 或智能体 API。非终端的原生插件窗格是之后的能力面。 + +`--env KEY=VALUE` 可以在启动进程的命令上重复使用,只作用于新启动的进程。当与调用方提供的环境变量冲突时,`HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ENV`、`HERDR_WORKSPACE_ID`、`HERDR_TAB_ID`、`HERDR_PANE_ID`、`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、`HERDR_PLUGIN_CONFIG_DIR`、`HERDR_PLUGIN_STATE_DIR`、`HERDR_PLUGIN_ENTRYPOINT_ID` 和 `HERDR_PLUGIN_CONTEXT_JSON` 等 Herdr 管理的变量保持权威。 + +## 读取来源 + +| 来源 | 含义 | +| --- | --- | +| `visible` | 当前渲染的屏幕。最适合 UI 反馈循环。 | +| `recent` | 带终端折行的最近回滚内容。 | +| `recent-unwrapped` | 不带软折行的最近回滚内容。最适合日志。 | +| `detection` | 智能体屏幕检测使用的底部缓冲区快照。 | + +此表描述读取时的含义。仅对 `pane wait-output` 而言,`recent` 和 `recent-unwrapped` 都搜索 recent 的未折行快照,默认写法仍是 `recent`。 + +## 环境变量 + +| 变量 | 用途 | +| --- | --- | +| `HERDR_CONFIG_PATH` | 覆盖配置文件路径。 | +| `HERDR_SESSION` | 为 CLI 命令选择命名会话。 | +| `HERDR_SOCKET_PATH` | 底层 socket 路径覆盖。 | +| `HERDR_ENV` | 在 Herdr 管理的窗格进程内设为 `1`。 | +| `HERDR_PANE_ID` | 运行中窗格进程的公开窗格 id。 | +| `HERDR_TAB_ID` | 运行中窗格进程的公开标签页 id。 | +| `HERDR_WORKSPACE_ID` | 运行中窗格进程的公开工作区 id。 | +| `HERDR_LOG` | 设置日志过滤,例如 `HERDR_LOG=herdr=debug`。 | +| `HERDR_DISABLE_SOUND` | 即使启用了声音通知也禁用声音播放。 | diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/concepts.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/concepts.mdx new file mode 100644 index 00000000..f94d5a93 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/concepts.mdx @@ -0,0 +1,89 @@ +--- +title: 核心概念 +description: 理解 Herdr 的工作区、标签页、窗格、智能体、会话和模式。 +--- + +Herdr 是一个终端工作区管理器。它让真实的终端进程持续运行,并在其之上添加结构。 + +## 工作区 + +工作区是最顶层的项目容器。为每个仓库、任务或调查使用一个工作区。 + +工作区拥有标签页和窗格。它在侧边栏中的状态由内部的智能体汇总而来,让你一眼看出哪个项目需要关注。 + +## 标签页 + +标签页是工作区内的一种布局。用标签页来分隔不同视图,比如 `agents`、`logs`、`server` 或 `review`。 + +标签页可以通过 CLI 和 socket API 寻址。 + +## 窗格 + +窗格是一个真实的终端。Herdr 渲染终端输出,把输入传回进程,并在客户端分离后保留窗格。 + +窗格可以向右或向下分割。它们可以手动重命名、通过 CLI 读取、接收输入,以及被关闭。 + +## 鼠标 UI + +Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智能体,可以拖动分割边框、选择文本、使用右键菜单。下文的所有操作都可以用鼠标完成;按键绑定是可选的一层。 + +如果你偏好纯键盘操作,或者不想让 Herdr 捕获鼠标输入,可以禁用鼠标捕获: + +```toml +[ui] +mouse_capture = false +``` + +## 智能体 + +智能体是 Herdr 在窗格内识别出的进程。Herdr 通过前台进程、屏幕清单和可选的集成来检测智能体。 + +智能体状态如下: + +| 状态 | 含义 | +| --- | --- | +| `blocked` | 智能体需要输入、审批或决策。 | +| `working` | 智能体正在运行。 | +| `done` | 智能体已完成,你还没有查看。 | +| `idle` | 智能体已完成或在等待,并且已被查看过。 | +| `unknown` | Herdr 无法有把握地判断状态。 | + +每个客户端独立记录它已显示的完成状态。在一个客户端查看完成状态,不会清除其他客户端的 Done 标记。CLI/API 使用服务器的已查看状态,因此不一定与某个客户端的标记相同;`idle` 和 `done` 都表示可以接受输入。 + +## 会话 + +会话是一个持久的 Herdr 服务器命名空间。默认的 `herdr` 命令连接到默认会话。 + +命名会话是彼此独立的运行时命名空间: + +```bash +herdr session list +herdr session attach work +herdr session attach side-project +``` + +优先使用工作区。当你需要完全独立的窗格、socket 和持久化运行时状态时,才使用命名会话。 + +## 客户端与服务器 + +默认情况下,Herdr 以一个后台服务器加一个或多个已连接客户端的方式运行。 + +服务器拥有窗格和进程状态。客户端是连接到该服务器的终端 UI。 + +只有一个客户端连接时,所有标签页会像以前一样采用该客户端的尺寸。连接多个客户端时,每个客户端都可以查看自己的工作区和标签页。不同标签页会分别采用查看它们的客户端尺寸;客户端查看同一标签页时,最后聚焦、选择或操作该标签页的客户端控制其中窗格的尺寸。 + +用 `ctrl+b q` 分离客户端。服务器和智能体会继续运行。 + +如果想结束会话并停止其中的窗格,停止服务器: + +```bash +herdr server stop +``` + +## 模式 + +Herdr 有终端模式、前缀模式和导航模式。 + +终端模式把按键发送给聚焦的窗格。前缀模式在按下前缀键后等待一个 Herdr 动作。导航模式是常驻的工作区导航界面。 + +按下前缀键 (默认 `ctrl+b`),再按一个动作键,比如 `c` 新建标签页,`w` 打开工作区导航。第一次接触前缀键的概念?参见[键盘](/zh-cn/docs/keyboard/)。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/config-reference.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/config-reference.mdx new file mode 100644 index 00000000..7f70afa2 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/config-reference.mdx @@ -0,0 +1,26 @@ +--- +title: 配置参考 +description: Herdr 的所有规范 config.toml 键,包含类型、默认值与允许的取值。 +--- + +import ConfigReference from '../../../components/ConfigReference.astro'; + +Herdr 从 `config.toml` 读取的所有规范键都在这里平铺列出,并支持筛选。 +配置的分步指南与各选项的背景说明见[配置](/zh-cn/docs/configuration/)。 + +随时可以打印带注释的完整默认配置: + +```bash +herdr --default-config +``` + +自定义命令绑定(`[[keys.command]]`)是用户自定义的表,因此不在此处逐键列出, +见[自定义命令按键绑定](/zh-cn/docs/configuration/#自定义命令按键绑定)。 + +各键的描述目前为英文。 + +<ConfigReference + placeholder="筛选键(例如 sidebar、prefix、sound…)" + noMatches="没有匹配的键。" + keyCountLabel="个键" +/> diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/configuration.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/configuration.mdx new file mode 100644 index 00000000..9c1d5b2d --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/configuration.mdx @@ -0,0 +1,584 @@ +--- +title: 配置 +description: 配置 Herdr 的按键绑定、主题、侧边栏行为、通知和高级选项。 +--- + +Herdr 无需配置文件即可使用。当你想自定义按键、主题、侧边栏布局、通知或高级行为时,再添加配置文件。 + +在查找某项设置或按键绑定?请在[配置参考](/docs/config-reference/)中搜索,其中列出了每个键、类型、默认值和允许值。本页重点介绍设置方法、常用配置方案,以及需要比参考表格更详细说明的配置结构。 + +## 配置文件 + +Herdr 从以下位置读取配置: + +```text +Linux and macOS: ~/.config/herdr/config.toml +Windows: %APPDATA%\herdr\config.toml +``` + +运行 `herdr --help` 可查看系统解析后的配置路径。 + +打印完整的默认配置: + +```bash +herdr --default-config +``` + +如果想从完整配置开始,可以将其保存为你的配置文件: + +```bash +herdr --default-config > ~/.config/herdr/config.toml +``` + +如果配置值无效,Herdr 会回退到安全的默认值,并显示启动警告。 + +当 `onboarding` 缺失或为 true 时,Herdr 会显示首次运行设置。继续完成引导会写入 `onboarding = false`,并打开设置中的集成标签页。完成设置后,如果想跳过此流程,请设置该值。 + +```toml +onboarding = false +``` + +## 重载配置 + +编辑 `config.toml` 后,重载正在运行的服务器: + +```bash +herdr server reload-config +``` + +你也可以打开 Herdr 的全局菜单并选择 `reload config`。 + +重载会在不重启窗格的情况下应用大多数 UI 设置。仅启动时生效的设置仍需重启。 + +主题、侧边栏布局、复制行为等显示设置来自客户端的本地配置,查看 SSH 机器时也一样。窗格默认值、worktree、集成和自定义命令属于运行窗格的服务器。UI 中的 `reload config` 会同时重载客户端本地设置和选中服务器的配置,本地按键绑定也会更新;指定 `--remote-keybindings server` 时则使用选中服务器的按键绑定。 + +## 无客户端终端尺寸 + +没有客户端连接时,服务器会使用 120×40 的虚拟终端进行布局并创建新窗格。可通过以下配置更改用于无客户端编排的回退尺寸: + +```toml +[server] +headless_cols = 160 +headless_rows = 50 +``` + +只有一个客户端连接时,所有标签页都会采用它的尺寸。连接多个客户端时,每个正在查看的标签页会采用最后聚焦、选择或操作它的客户端尺寸。当只剩一个客户端时,所有标签页会立即恢复为该客户端的尺寸。没有客户端连接时,现有窗格 PTY 会保留最后的尺寸,新的无客户端布局会使用配置的回退值。 + +## 终端默认值 + +设置 Herdr 创建新交互式窗格时使用的可执行文件: + +```toml +[terminal] +default_shell = "nu" +``` + +未设置或值为空时,Herdr 会依次使用 `$SHELL`、Unix 上的 `/bin/sh` 或 Windows 上的 PowerShell。该值是可执行文件名或路径,而不是 shell 命令行。现有窗格会保留当前 shell,直到重新创建。自定义命令按键绑定中的字符串在 Unix 上通过 `/bin/sh -c` 执行窗格命令,通过 `/bin/sh -lc` 执行分离式命令;在 Windows 上则通过 `cmd.exe /d /c` 执行。 + +设置 Herdr 如何启动新建交互式窗格的 shell: + +```toml +[terminal] +shell_mode = "auto" +``` + +`shell_mode = "auto"` 会在 macOS 上启动登录 shell,让 `/usr/libexec/path_helper` 等仅登录时生效的 PATH 设置和 Homebrew shell 初始化在新窗格中运行。在其他平台上,它会保持现有的非登录 shell 行为。使用 `"login"` 强制以登录 shell 启动,或使用 `"non_login"` 禁用登录 shell。命令窗格、分离式自定义命令按键绑定和显式 argv 启动仍使用现有的命令执行路径。 + +设置新窗格、标签页和工作区的工作目录策略: + +```toml +[terminal] +new_cwd = "follow" +``` + +`new_cwd = "follow"` 保持默认行为,并继承来源窗格或工作区。没有来源工作区时,Herdr 从 `$HOME` 启动。使用 `"home"` 可始终从 `$HOME` 启动,使用 `"current"` 可采用 Herdr 的进程目录,也可以指定 `"~/Projects"` 这样的固定路径。CLI 或 socket API 显式提供的 `--cwd` 值仍然优先。 + +## Worktree + +设置 Herdr 从侧边栏创建 Git worktree 检出时使用的根目录: + +```toml +[worktrees] +directory = "~/.herdr/worktrees" +``` + +Herdr 会在 `<directory>/<repo>/<branch-slug>` 下创建检出。若要使用同级目录风格的检出,请将其设置为 `~/Projects/herdr-worktrees` 这样的目录。应用配置时,相对路径会解析为绝对路径。 + +Git 工作区行提供 worktree 操作。`New worktree` 会创建检出:如果输入的分支已存在,则检出该本地分支,否则创建该分支;随后将其作为新的 Herdr 工作区打开,并归组到来源工作区下。`Open worktree...` 会列出该仓库现有的 Git worktree 检出;选择已打开的检出会聚焦它,选择尚未打开的检出会在同一组中打开它。 + +归组的 worktree 仍像普通 Herdr 工作区一样工作:可以聚焦、重命名和关闭,也可以拥有自己的标签页和窗格。父行是原始工作区。关闭父行会关闭整个 Herdr 组,但不会删除检出目录或分支。 + +删除 worktree 检出需要显式操作。在归组的子工作区上使用 `Delete worktree checkout...` 来运行 `git worktree remove`。Herdr 会先请求 Git 安全删除。如果 Git 因检出中存在已修改或未跟踪的文件而拒绝,Herdr 会在强制删除前再次确认。分支不会被删除。 + +## 远程连接 + +远程连接默认使用临时保活设置,并在支持时复用连接,以管理 SSH 连接。 + +```toml +[remote] +manage_ssh_config = true +``` + +启用后,`herdr --remote` 会写入一份私有的临时 SSH 配置:先包含用户和系统 SSH 配置,再添加兜底的 `ServerAliveInterval` 和 `ServerAliveCountMax` 值。你自己的保活设置优先。Linux 和 macOS 客户端还会为每次远程连接使用私有的 OpenSSH control socket,以复用首次通过身份验证的连接;Windows OpenSSH 不使用此复用方式。设置 `manage_ssh_config = false` 可通过普通 `ssh` 进行远程连接,不使用 Herdr 生成的配置或 control socket。 + +## 按键绑定 + +有关前缀键的引导式介绍和经过验证的免前缀配置,请参阅[键盘](/docs/keyboard/)。 + +Herdr 提供类似 tmux 的前缀模式。默认前缀是 `ctrl+b`。按键绑定字符串是显式的:`prefix+n` 表示先按配置的前缀,再按 `n`;`ctrl+alt+n` 则是终端模式下的直接快捷键。 + +一个简短的按键绑定覆盖如下: + +```toml +[keys] +prefix = "ctrl+b" +goto = "prefix+g" +new_tab = "prefix+c" +next_tab = "prefix+n" +previous_tab = "prefix+p" +focus_pane_left = "prefix+h" +navigate_workspace_down = "j" +navigate_pane_down = "ctrl+j" +split_horizontal = "prefix+minus" +``` + +默认键位以前缀为主,因此 Herdr 不会抢占 shell、编辑器、tmux 或终端应用的输入。在[配置参考](/docs/config-reference/)中搜索 `keys.` 可查看每个动作及其默认绑定。应用内帮助面板可通过 `prefix+?` 打开,其中会显示当前生效的绑定。 + +当一个动作需要多个快捷键时,绑定也可以是数组: + +```toml +[keys] +next_tab = ["prefix+n", "ctrl+alt+]"] +``` + +可选动作默认不设置。使用 `prefix+` 可获得前缀模式行为;当你确实需要直接快捷键时,请使用显式的修饰组合键。例如,可以像 tmux 一样通过一次按键调整窗格大小,而无需进入调整大小模式: + +```toml +[keys] +resize_pane_left = "ctrl+shift+alt+left" +resize_pane_down = "ctrl+shift+alt+down" +resize_pane_up = "ctrl+shift+alt+up" +resize_pane_right = "ctrl+shift+alt+right" +``` + +按键字符串支持普通按键、`ctrl+a`、`shift+n`、`alt+1`、`cmd+k` 等修饰组合键,以及 `enter`、`tab`、`esc`、`left`、`right`、`up`、`down` 等特殊键。也支持 `minus`、`comma`、`ampersand`、`plus`、`backtick` 等命名标点。直接绑定 `n` 这样的普通可打印键并不安全,因为它会拦截输入;除非你有意设置直接绑定,否则请使用 `prefix+n`。`navigate_workspace_*` 和 `navigate_pane_*` 字段仅在导航模式中生效,可以使用 `j` 或 `k` 等普通按键;它们不得使用 `prefix+`、`esc`、`enter`、`tab`、`shift+tab`、`left`、`right`,也不得使用无修饰键的 `1` 到 `9`。左右方向键是向左和向右导航窗格的永久别名。这些导航模式快捷键独立于 `focus_pane_down = "prefix+j"` 等通用动作绑定;两者使用同一按键时,打开导航模式后,导航模式快捷键优先。Alt、Cmd/Super 以及带修饰键的标点取决于你的终端和 tmux 设置。 + +如果你已有旧的自定义按键绑定,并想使用新的默认值,请运行 `herdr config reset-keys`。Herdr 会备份 `config.toml`,移除 `[keys]` 和 `[[keys.command]]`,并在重启或执行 `herdr server reload-config` 后使用内置的 v2 默认值。 + +## 索引跳转 + +索引式按键绑定在普通按键绑定字段中使用 `1..9`: + +```toml +[keys] +switch_tab = "prefix+1..9" +switch_workspace = "prefix+shift+1..9" +focus_agent = "prefix+alt+1..9" +``` + +旧版 `[keys.indexed]` 表仍会出于兼容性而解析,但新配置应优先使用显式的动作字段。 + +## 自定义命令按键绑定 + +自定义命令使用相同的按键绑定语法。 + +```toml +[[keys.command]] +key = "prefix+alt+g" +type = "popup" +command = "lazygit" +description = "run lazygit" +width = "80%" +height = "80%" +``` + +`type = "popup"` 会打开一个会话级模态弹窗,而不改变标签页布局。 +弹窗会接收包括 Escape 在内的所有终端输入,直到命令退出。 +`width` 和 `height` 是可选的;省略时默认为终端大小的一半,数字表示终端单元格数,`"80%"` 这样的字符串表示终端区域的百分比。 +尺寸包含弹窗边框,过小的值会限制为弹窗最小尺寸。 +弹窗命令不会收到 `HERDR_PANE_ID`;可使用 `HERDR_ACTIVE_PANE_ID` 引用底层平铺窗格。 + +在 Unix 和 macOS 上,还可以打开一个临时终端,而无需添加分割或标签页: + +```toml +[[keys.command]] +key = "prefix+t" +type = "popup" +command = "exec \"${SHELL:-sh}\"" +description = "open scratch terminal" +width = "80%" +height = "80%" +``` + +在 Windows 上,请改用 `command = "powershell.exe -NoLogo"` 之类的 shell 命令。 +退出 shell 即可关闭弹窗并恢复平铺终端视图。 + +`type = "pane"` 会打开一个临时窗格,并在命令退出时关闭它。 + +`type = "shell"` 会在后台分离运行。 + +`type = "plugin_action"` 会调用已安装插件的动作 id。当动作 id 并非全局唯一时,请使用限定 id: + +```toml +[[keys.command]] +key = "prefix+l" +type = "plugin_action" +command = "example.layout.apply" +description = "apply layout" +``` + +可以提供可选的 `description`。指定后,该描述会在按键绑定帮助面板(通过 `prefix+?` 打开)中显示,替代默认的 `'custom command'` 标签。 + +在相应值可用时,自定义命令会收到 `HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ACTIVE_WORKSPACE_ID`、`HERDR_ACTIVE_TAB_ID`、`HERDR_ACTIVE_PANE_ID` 和 `HERDR_ACTIVE_PANE_CWD`。当 Herdr 能检测到聚焦窗格的工作目录时,shell 命令会从该目录运行。 + +在 Windows 上,自定义命令字符串使用 `cmd.exe /d /c`,因此环境变量应采用 `%HERDR_BIN_PATH%` 语法。若要运行 PowerShell 语法,请显式调用它,例如 `powershell.exe -NoProfile -Command "..."`。 + +## 主题 + +选择内置主题: + +```toml +[theme] +name = "catppuccin" +``` + +在[配置参考](/docs/config-reference/)中搜索 `theme.name` 可查看所有内置主题。如果想让 Herdr UI 颜色跟随宿主终端的 ANSI 调色板,请使用 `terminal`。 + +要让 Herdr 在宿主终端报告明暗外观变化时自动切换自身的 UI 主题,请启用主题自动切换: + +```toml +[theme] +name = "catppuccin" +auto_switch = true +light_name = "catppuccin-latte" +dark_name = "catppuccin" +``` + +`auto_switch` 默认为 `false`,因此现有主题配置会保持手动行为。如果省略 `light_name` 或 `dark_name`,且配置的 `name` 存在对应的内置姊妹主题,Herdr 会使用该主题,例如 `tokyo-night`/`tokyo-night-day` 或 `gruvbox`/`gruvbox-light`。在设置中手动选择主题会禁用 `auto_switch`。 + +你可以覆盖单个颜色: + +```toml +[theme.custom] +sidebar_bg = "#181825" +active_row_bg = "#1e1e2e" +selection_bg = "#313244" +panel_bg = "reset" +accent = "#a6e3a1" +green = "#a6e3a1" +blue = "#89b4fa" +red = "#f38ba8" +yellow = "#f9e2af" +``` + +`sidebar_bg` 可单独设置桌面侧边栏的背景色。省略时,侧边栏继续使用宿主终端背景。`active_row_bg` 可更改当前 Space 和已聚焦 Agent 行的背景色,而不会影响分隔线或滚动条轨道。`selection_bg` 可更改侧边栏中 Navigate 模式光标行的背景色。 + +颜色值支持十六进制、命名颜色、`rgb(r,g,b)`,以及 `reset`、`default`、`none`、`transparent` 等重置别名。 + +启用 `auto_switch` 后,可以通过明暗模式子表在共享自定义颜色之上应用不同的覆盖值: + +```toml +[theme.custom] +accent = "#89b4fa" + +[theme.custom.light] +panel_bg = "#eff1f5" +text = "#4c4f69" + +[theme.custom.dark] +panel_bg = "#1e1e2e" +text = "#cdd6f4" +``` + +有效调色板按以下顺序应用:内置主题、`[theme.custom]`,然后是 `[theme.custom.light]` 或 `[theme.custom.dark]`。省略模式子表时,会保留现有的共享覆盖行为。 + +## UI 与侧边栏 + +侧边栏是 Herdr 的主仪表盘。在[配置参考](/docs/config-reference/)中搜索 `ui.`,可查看尺寸、折叠模式、Agent 面板排序、鼠标行为、窗格边框和其他显示设置。 + +`ui.pane_borders` 接受 `"auto"`(默认值,仅为分割窗格显示边框)、`"always"`(也为单个窗格显示边框)或 `"off"`。单窗格的所有边都是外边,因此还需要 `ui.pane_outer_borders = true` 才会显示边框。旧的布尔值仍然有效:`true` 对应 `"auto"`,`false` 对应 `"off"`。 + +在 `[ui]` 下设置 `tab_bar_position = "bottom"`,可将桌面标签栏放到终端窗格下方。Prefix、Navigate、Copy 和 Resize 模式栏会在显示期间临时替换底部标签栏。默认值为 `"top"`。 + +可在标签栏右侧配置一个类似 tmux 的有序状态区: + +```toml +[ui] +tab_bar_right = [ + { type = "zoom" }, + { type = "hostname" }, + { type = "datetime", format = "%H:%M" }, + { type = "text", text = "prod" }, + { type = "command", command = "~/.config/herdr/status.sh", interval_seconds = 5, timeout_seconds = 2 }, +] +tab_bar_right_separator = " · " +``` + +状态区默认为空。添加 `zoom` 后,活动标签页处于缩放状态时会显示固定的 `ZOOM` 标记;它与现有的逐标签页 `Z` 标记相互独立。`hostname`、`datetime` 和 `command` 在 Herdr 服务器上解析,因此使用 `herdr --remote` 时会显示远程机器的值。日期时间条目使用 `strftime` 格式。由于该值是服务器本地的墙上时钟时间,需要 UTC 偏移量或 Unix 时间戳的 `%z`、`%s` 等指令会被拒绝。 + +命令条目会立即运行,之后按 `interval_seconds` 刷新,不会阻塞渲染,也不会与上一次运行重叠。间隔范围为 1–31,536,000 秒,超时范围为 1–3,600 秒。Herdr 使用成功输出的最后一行,并移除以 ESC 开头的终端控制序列,而不将其解释为样式;执行失败、输出为空或超过 `timeout_seconds` 后会清除该值。命令会获得与自定义命令按键绑定相同的活动工作区、标签页、窗格、socket、二进制文件和工作目录上下文。Linux、macOS 和 Windows 均支持命令条目;Linux 和 macOS 使用 `/bin/sh -lc`,Windows 使用 `cmd.exe /d /c`。 + +分隔符只会出现在可见条目之间。设置 `tab_bar_right_separator = ""` 可直接连接条目。标签栏较窄时,整个状态区会让位给标签页及其控件。 + +### 外层终端窗口标题 + +Herdr 会模拟各窗格中的终端,因此窗格内写入的 `OSC 0`/`OSC 2` 标题只会停留在 Herdr。Herdr 会向自己所运行的终端写入自己的标题,窗口管理器和终端标签栏读取的正是这个标题: + +```toml +[ui] +window_title = "{hostname}: {workspace}" +``` + +可用记号为 `{hostname}`、`{workspace}`、`{tab}`、`{pane}`(聚焦窗格的手动名称)和 `{terminal_title}`(聚焦窗格自身的终端标题,已去除加载动画字符)。用 `{{` 和 `}}` 表示字面花括号。没有值的记号会渲染为空。 + +标题在 Herdr 服务器上生成,因此即使通过 `herdr --remote` 连接或经由 SSH 运行 `herdr`,`{hostname}` 仍然指向运行窗格的那台机器。设置 `window_title = ""` 可以不改动外层终端标题。 + +`client.window_title.set` 会覆盖配置的标题,直到 `client.window_title.clear` 将其交还。 + +智能体状态默认使用紧凑的彩色圆点。若要同时通过形状和颜色区分 blocked、working、done、idle 与 unknown 状态,请在设置中选择 **distinct symbols**,或配置: + +```toml +[ui] +status_indicators = "symbols" +``` + +这些符号是静态的,因此该选项不会启用旋转动画。 + +### 侧边栏行布局 + +展开的桌面侧边栏会将 `rows` 中的每个内层数组渲染为一行。以下是完整的默认布局: + +```toml +[ui.sidebar.agents] +row_gap = 0 +rows = [ + ["state_icon", "machine", "workspace", "tab"], + ["agent"], +] + +[ui.sidebar.spaces] +row_gap = 0 +rows = [ + ["state_icon", "workspace"], + ["branch", "git_status"], +] +``` + +Agent 行支持以下内置 token: + +- `state_icon` — 智能体语义状态的彩色图标。 +- `state_text` — `idle`、`working`、`blocked`、`done` 或 `unknown`;如果报告中包含显示标签,也会一并显示。 +- `machine` — 客户端连接多台机器时显示机器标签;只有一台本地机器时省略。 +- `workspace` — 工作区名称。 +- `tab` — 标签页名称(如有)。 +- `pane` — 窗格名称(如有)。 +- `agent` — 检测到或报告的智能体显示名称。 +- `terminal_title` — 经过安全规范化的最新 OSC 0/2 终端标题。 +- `terminal_title_stripped` — 从终端标题开头移除一个可识别的活动或旋转指示符字形及其后空白后的结果。 +- `$name` — 名为 `name` 的自定义窗格元数据。 + +Space 行支持以下内置 token: + +- `state_icon` — Space 汇总后智能体状态的彩色图标。 +- `state_text` — Space 汇总后智能体状态的文本。 +- `workspace` — 工作区名称。 +- `branch` — Git 分支(如有)。 +- `git_status` — 非零时显示 Git ahead 和 behind 数量。 +- `$name` — 名为 `name` 的自定义工作区元数据。 + +token 会按配置顺序渲染。Herdr 通常使用 ` · ` 分隔相邻值,并在 `state_icon` 后使用一个空格。缺失值及其分隔符会消失;当一行中的所有 token 都没有值时,该行会消失。已有的自定义行不会改变;如果希望在自定义的多机器布局中显示机器标识,请显式添加 `machine`。每个布局最多可包含 16 行,每行最多可包含 16 个 token。 + +也可以用内联样式表定义 token: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", { token = "workspace", bold = false }, "tab"], + [{ token = "$summary", fg = "#89b4fa", bold = true, dim = false }], +] +``` + +`fg` 只接受严格的 `#RGB` 或 `#RRGGBB`,`bold` 和 `dim` 接受布尔值。省略字段会保留上下文样式;显式 `false` 会移除对应修饰。样式只作用于当前出现位置。为 `git_status` 设置 `fg` 后,ahead 和 behind 会使用同一种颜色,而不是默认的绿色和红色。token 样式不会改变分隔符或行背景。 + +文本值 token 还可以包含最多 16 条有序的 `rules`。每条规则必须恰好包含一个条件:`equals`、`contains`、`starts_with`、`gt` 或 `lt`,并可选地覆盖 `fg`、`bold` 和 `dim`: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "workspace", "tab"], + [{ token = "machine", fg = "#fff", rules = [{ equals = "Local", fg = "#f55" }, { equals = "Fedora", ignore_case = true, fg = "#51a2da" }] }, "agent"], + [{ token = "$load", fg = "#fff", rules = [{ gt = 80, fg = "#f55", bold = true }, { gt = 50, fg = "#fc0" }] }], +] +``` + +只有第一条匹配的规则生效。指定的样式字段覆盖当前位置的默认值,未指定的字段保持继承。没有规则匹配时保留默认样式。匹配使用显示截断前的完整值。在规则中设置 `hide = true` 可移除匹配的 token 及其分隔符;没有剩余 token 的行也会消失。例如,`{ token = "machine", fg = "#61afef", rules = [{ equals = "Local", hide = true }] }` 会在机器标签恰好为 `Local` 时隐藏机器 token。它比较的是标签,而不是连接类型。设置 `hide = false` 或省略时,token 保持可见。没有覆盖字段的规则一旦匹配,仍会停止后续匹配并保留默认值。 + +`equals`、`contains` 和 `starts_with` 接受字符串,默认区分大小写。添加 `ignore_case = true` 可忽略 ASCII 大小写,非 ASCII 字符仍区分大小写。空字符串遵循普通字符串匹配:空的 `equals` 只匹配空值,空的 `contains` 或 `starts_with` 匹配任何已存在的值。 + +`gt` 和 `lt` 接受有限数值阈值,分别进行严格大于和小于比较,不包含等于。整个值必须能解析为有限数值;支持小数和指数形式,但带空白、单位、`NaN` 或无穷大的值不会匹配。数值规则不接受 `ignore_case`。比较使用浮点数,不保证大整数的精确比较。 + +规则适用于 Agent 行、`rows_by_agent` 覆盖和 Space 行中的文本值内置 token 及自定义 `$name` token。上例中,`$load` 报告 `"90"` 时显示红色粗体,`"60"` 时显示黄色,`"90%"` 时保持白色。未报告的 token 仍然不显示。`state_icon` 和复合 token `git_status` 仅接受固定样式,不支持规则。未知条件或格式错误的规则会在加载配置时被拒绝;不支持正则表达式、模糊匹配或脚本。 + +`row_gap` 分别控制 Agent 和 Space 面板中条目之间的空白终端行数。默认值为 `0`,会紧密排列条目;设为 `1` 可恢复之前的间距。它不会在 `rows` 声明的内容行之间添加间距。连续缩进的 worktree 子项仍会作为一个 Space 组紧密排列。 + +在 `rows_by_agent` 下为已知智能体覆盖完整的 Agent 布局: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "agent", "state_text"], + ["workspace", "tab"], +] + +[ui.sidebar.agents.rows_by_agent] +claude = [ + ["state_icon", "agent", "state_text"], + ["terminal_title_stripped"], + ["workspace", "tab"], +] +``` + +覆盖项会替换 `rows`,而不是扩展它。覆盖项的键是区分大小写的规范智能体 ID,例如 `claude`、`codex` 和 `pi`。不接受 `claude-code` 等检测别名。没有覆盖项的智能体(包括自定义报告的智能体)会使用 `rows`。 + +自定义 `$name` token 是动态值,而不是字面文本。先将 token 添加到布局,再通过脚本或插件报告其值: + +```toml +[ui.sidebar.agents] +rows = [ + ["state_icon", "agent", "$model"], + ["$summary"], + ["workspace", "tab"], +] +``` + +```bash +herdr pane report-metadata <pane_id> \ + --source my-agent-hook \ + --token model=opus \ + --token summary="reviewing authentication" +``` + +自定义 Space token 可用同样的方式通过 `herdr workspace report-metadata` 报告。未报告的自定义 token 会直接消失。 + +元数据上报方只提供值;样式由本地侧边栏配置控制。有关限制、清除、顺序和过期值的信息,请参阅 [CLI 参考:报告元数据](/docs/cli-reference/#panes)。 + +侧边栏行设置仅影响展开的桌面侧边栏。折叠视图和移动视图仍使用紧凑布局。 + +## 通知 + +当后台智能体完成任务或需要输入时,Herdr 可以通知你: + +```toml +[ui.toast] +delivery = "herdr" +delay_seconds = 1 + +[ui.toast.herdr] +position = "bottom-right" +``` + +选择 `herdr` 可使用应用内 toast,选择 `terminal` 可使用适合 SSH 场景的外层终端通知,选择 `system` 可使用本地操作系统通知服务,选择 `off` 可禁用弹出通知。Herdr 会抑制活动标签页的弹出通知。在[配置参考](/docs/config-reference/)中搜索 `ui.toast`,可查看位置、延迟行为和剪贴板反馈设置。 + +在 macOS 上,`system` 会先尝试 `terminal-notifier`;如果它不可用或执行失败,则回退到 `/usr/bin/osascript`。该回退方式会在通知中心显示为 Script Editor,并且无法激活承载 Herdr 的终端。可运行 `brew install terminal-notifier` 安装 `terminal-notifier`。如果 Herdr 检测到受支持的终端,点击通知时它可以激活该终端应用。也可以选择 `terminal`,让受支持的外层终端负责发送通知。 + +## 声音 + +声音通知通过本地 Herdr 客户端播放。自定义声音必须是 mp3 文件;相对路径从配置文件所在目录解析。 + +```toml +[ui.sound] +path = "sounds/notification.mp3" +done_path = "sounds/done.mp3" +request_path = "sounds/request.mp3" +``` + +`path` 为所有声音通知设置同一种声音。`done_path` 和 `request_path` 只覆盖完成和需要输入时的声音。 + +按智能体覆盖声音设置时,可使用 `default`、`on` 或 `off`。键应使用检测到的智能体标签,例如 `claude`、`codex`、`devin` 或 `droid`。Droid 默认静音。 + +```toml +[ui.sound.agents] +droid = "off" +claude = "on" +``` + +## 高级配置 + +在[配置参考](/docs/config-reference/)中搜索回滚缓冲区限制、嵌套启动以及其他高级或实验性设置。启用窗格屏幕历史前,请参阅[会话状态与恢复](/docs/session-state/);该指南说明了保存窗格内容带来的安全取舍。 + +## Kitty graphics + +Herdr 默认在兼容的外层终端中渲染窗格图像。图像放置区域与弹窗、菜单或通知重叠时,Herdr 会暂时隐藏整个图像放置区域,而不是只裁剪重叠部分。未被遮挡的图像保持可见,被隐藏的图像放置区域在遮挡消失后恢复显示。图像不会随对话框背景一起变暗。 + +要禁用图形渲染和窗格图形 API,请设置: + +```toml +[terminal] +kitty_graphics = false +``` + +为兼容现有配置,旧的 `experimental.kitty_graphics` 设置仍然可用。若两者同时设置,`terminal.kitty_graphics` 优先。 + +更改此设置后,需要重启受影响的 Herdr 服务器或重新连接客户端。在远程会话中,服务器端设置控制窗格图形解析和 API 可用性,本地客户端设置控制向外层终端输出图形。 + +## 智能体会话恢复 + +默认情况下,Herdr 会在服务器重启后恢复受支持的智能体对话: + +```toml +[session] +resume_agents_on_restore = true +``` + +只有通过官方集成获得有效原生会话引用的窗格才能恢复;其他窗格会作为普通 shell 恢复。有关受支持的智能体和持久化行为,请参阅[会话状态与恢复](/docs/session-state/)。 + +## IME 光标跟踪 + +在 macOS 上,隐藏硬件光标的 AI 智能体 TUI 可能导致原生输入法候选窗口无法跟随聚焦窗格。使用以下设置可为这些窗格显示光标锚点: + +```toml +[experimental] +reveal_hidden_cursor_for_cjk_ime = true +cjk_ime_agents = ["claude", "pi", "codex"] +``` + +限制 `cjk_ime_agents` 可以避免在无关应用中额外显示硬件光标。在[配置参考](/docs/config-reference/)中搜索这些键,可查看接受的智能体名称和光标形状。 + +## 前缀输入源切换 + +在 macOS 和 Windows 上,当前缀命令和由前缀启动的模式处于活动状态时,Herdr 可以临时切换到支持 ASCII 的输入源: + +```toml +[experimental] +switch_ascii_input_source_in_prefix = true +``` + +在 macOS 上,会切换到当前支持 ASCII 的键盘布局;在 Windows 上,会将 IME 切换到英文(ASCII)输入。返回终端输入或进入文本字段时,Herdr 会恢复之前的输入源。此设置在其他平台上无效。 + +:::caution[Windows 上仅支持韩语 IME] +目前 Windows 上的支持仅限于韩语 IME。使用其他语言的 IME 时,此设置不会改变输入源。 +::: + +## 环境变量 + +| 变量 | 用途 | +| --- | --- | +| `HERDR_CONFIG_PATH` | 覆盖配置文件路径。 | +| `HERDR_SESSION` | 为 CLI 命令选择命名会话。 | +| `HERDR_SOCKET_PATH` | 覆盖底层 socket 路径。 | +| `HERDR_LOG` | 设置日志过滤,例如 `HERDR_LOG=herdr=debug`。 | +| `HERDR_DISABLE_SOUND` | 即使 `[ui.sound] enabled = true` 也禁用声音播放。 | + +## 日志 + +日志有助于诊断启动警告、集成状态或 socket API 行为。 + +常见日志文件: + +```text +~/.config/herdr/herdr.log +~/.config/herdr/herdr-client.log +~/.config/herdr/herdr-server.log +``` + +日志会自动轮转。报告问题时,请附上当前日志和轮转后的同级日志文件。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/connecting-machines.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/connecting-machines.mdx new file mode 100644 index 00000000..4175d5ee --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/connecting-machines.mdx @@ -0,0 +1,108 @@ +--- +title: 连接机器 +description: 在一个 Herdr 窗口中使用 Local 和已保存的 SSH 机器,集中查看智能体,各连接独立重连。 +--- + +将本地工作和远程智能体放在同一个 Herdr 窗口中。SSH 机器只需保存一次,就能在它的工作区与 Local 之间切换,无需再开一个客户端。智能体列表汇总已连接的机器,让你看到工作在哪里运行,以及谁在等待回答。 + +每台机器保留自己的 Herdr 服务器、会话和进程。一台机器断线不会影响其他连接。 + +## 连接前的准备 + +你需要能够通过普通 SSH 访问远程机器。先验证: + +```bash +ssh workbox +``` + +`workbox` 可以是 SSH 配置中的主机名,也可以使用 `ssh://you@server:2222` 这样的目标。 + +多机器连接支持 Linux、macOS 和 Windows 客户端,连接目标可以是 x86_64 或 aarch64 的 Linux/macOS 服务器,也可以是 x86_64 的 Windows 服务器。交互式设置可在确认后安装或更新完整的远程软件包;后台重连只查找已安装的软件包。有关 SSH 配置、认证和自定义二进制文件,请参阅[通过 SSH 远程连接](/zh-cn/docs/persistence-remote/)。 + +## 添加机器 + +请在交互式终端中运行,以便 Herdr 在安装或替换内容之前询问: + +```bash +herdr machine add workbox --label "Build machine" +``` + +这会使用远程默认会话。一个机器配置只对应一个远程会话,不会汇总该主机的所有会话。 + +如需命名会话,再添加可选的 `--remote-session`: + +```bash +herdr machine add workbox --label "Build machine" --remote-session agents +``` + +Herdr 分别检查已安装的二进制文件和运行中的服务器,并在保存配置之前启动目标后台服务器。客户端和服务器版本只要兼容,就不必一致。未安装或不兼容时会进入需要批准的设置流程。如果必须替换运行中的服务器,会先询问是否停止它及其窗格进程,再启动兼容的服务器。默认回答为 No;同时需要安装和替换时,一次确认涵盖两项操作。`machine add` 不使用实验性的实时交接。取消或设置失败时,不会保存配置。 + +运行 `herdr` 打开 UI。如果本地客户端已经打开,新增或启用的机器通常会在一秒内出现并在后台连接,不改变当前选择。切换机器期间的配置变化会在切换完成后应用。设置结束后,远程服务器仍会运行。 + +## 切换机器 + +在侧边栏选择机器或其工作区。只有选中的机器接收窗格输入和终端尺寸,并提供可见的终端内容和图形。其他已连接机器仍会更新工作区信息、智能体状态和通知,但不传输窗格画面。 + +点击任意机器旁的箭头,可以折叠或展开其工作区列表,而不切换当前工作区。机器正在重连时也可以操作。 + +键盘导航先按 `prefix+w`,再使用工作区导航键(默认为 Up/Down),按侧边栏顺序高亮已连接机器上的工作区。Enter 确认切换;Esc 或前缀键取消,不切换机器。紧凑和展开的侧边栏都会显示高亮行,包括折叠机器内的行。桌面导航在两端循环,移动端切换器在首尾停止。已断开的机器会被跳过。 + +高亮其他机器的工作区后,请先按 Enter 再使用其他键盘操作,避免误对当前机器执行窗格、标签页、工作区或自定义命令。点击会取消桌面上的远程键盘导航预览。展开的侧边栏会保留每台机器的 worktree 分组折叠状态。 + +启动时会立即打开 Local,不等待 SSH 连接。选择 Local 也会立即取消尚未完成的远程切换,无需等待远程响应。如果 Local 自身正在重连,会在就绪后继续选中它。收到 Local 的新画面和终端设置后即可输入。一台机器无响应不会阻塞其他机器的输入。多个 Herdr 客户端也可以独立查看同一服务器上的不同标签页;共享标签页的尺寸规则见[客户端与服务器](/zh-cn/docs/concepts/)。 + +断线后,最后的工作区和智能体状态仍然可见,但会变暗。这是缓存,不是实时状态。在新连接和对应画面到达前,不能输入或导航到这些缓存窗格。重连不会从正在使用的机器抢走选择。 + +## 重命名、禁用或移除 + +从列表读取配置 ID,不要根据标签或主机名推测: + +```bash +herdr machine list +herdr machine rename <profile-id> --label "New name" +herdr machine disable <profile-id> +herdr machine enable <profile-id> +herdr machine remove <profile-id> +``` + +用于脚本时,可给 `machine list` 添加 `--json`。 + +重命名只修改显示标签,不会重连。禁用会保留配置供以后使用,移除则会忘记该配置。两者都只从客户端断开该机器,即使主机无法访问也可以操作;远程会话和智能体仍会运行。 + +移除或禁用正在查看的机器会返回 Local。如果 Local 不可用,Herdr 会显示该状态并重试连接,而不是选择其他远程机器。有已启用的保存机器时,即使 Local 失败或重启,客户端仍可继续使用。 + +## 连接问题 + +- **Reconnecting:** 网络中断、睡眠或 SSH 故障后,Herdr 会自动重试。连续失败会将间隔增加到最多两分钟,短暂连接成功不会重置间隔。连接稳定一分钟后,下一次中断才会恢复快速重试。SSH 连接会监测应用层活动并在空闲时探测,避免已断开的连接一直显示 Online。Local 使用本地连接关闭或失败来判断断线,而不是远程健康探测。 +- **Attention:** 目标需要无法在后台完成的操作,例如批准主机密钥、认证或准备兼容服务器。其他机器仍可使用。 +- **保存机器文件错误:** 目录文件无法读取或内容无效时,当前连接保持不变。Herdr 会显示通知并自动重试读取。 + +当两端都支持空闲桥接清理时,连接 Linux/macOS 的保存机器会在双向均无流量一分钟后关闭远程桥接。睡眠时间也计入期限,并在唤醒时检查。正常的空闲连接会交换健康检查,持续查看输出无需键盘输入。清理不会停止远程 Herdr 服务器、会话或窗格进程。旧安装仍可兼容连接,只是不使用这项可选清理功能。 + +后台连接不会回答提示,也不会安装、更新、重启或交接服务器。对于 Attention,请在交互式终端运行 Herdr 显示的独立设置命令,例如: + +```bash +herdr --remote workbox +``` + +使用配置中的目标。如果添加时选择了命名会话,再加上可选的 `--session <name>`。完成批准提示后,重启客户端以重试 Attention 连接。不要仅因为版本与客户端不同就停止运行中的服务器。 + +认证失败时,先检查普通 SSH。对于有口令的私钥,请在启动 Herdr 的非交互式后台连接前用 `ssh-add` 加载。 + +## 设置与自动化 + +UI 默认使用客户端本地的主题、侧边栏设置和按键绑定。选中服务器公布的自定义命令和插件仍在那里运行。Herdr 不会将本地插件、配置、可执行文件或机密复制到 SSH 主机。远程缺少命令时会明确报错。编辑客户端设置后,使用 UI 中的 `reload config`;参见[配置](/zh-cn/docs/configuration/)。 + +有多台机器时,默认智能体行会显示 `machine` token。已有的自定义行保持不变;需要机器标签时请显式加入 `machine`。[侧边栏行布局](/zh-cn/docs/configuration/)还支持按机器标签设置条件颜色。 + +工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。两台机器可能都有 `w1:p1` 或名为 `reviewer` 的智能体。在 UI 中选择机器不会改变已有窗格中 CLI 命令的目标,它们仍使用继承的会话和 socket。远程自动化可使用 `herdr --machine <label-or-id> agent list` 获取 ID,并在后续操作中使用同一前缀。CLI 直接使用保存的 SSH 目标和会话,不需要打开 TUI。省略 `--machine` 时现有行为不变。支持的命令、更新要求和远程路径规则见 [CLI 参考](/zh-cn/docs/cli-reference/)。 + +## 更新与保存的数据 + +配置只保存不透明 ID、标签、SSH 目标、明确的远程会话名和启用状态。Herdr 不会将密码、私钥、智能体票据或 SSH 控制套接字存入机器目录。认证仍由 OpenSSH 处理。 + +客户端与服务器协商兼容性,不要求版本完全相同。保存机器的连接还需要服务器支持 `surface_interest` 和 `health_check` 能力。缺少这些能力的旧服务器,即使能独立连接,也会显示 Attention,直到明确更新。其他缺失的方法只禁用对应操作。 + +更新兼容客户端不会替换远程服务器或停止其智能体。需要新的服务器行为时,请明确更新该服务器。正常替换会在停止服务器及其窗格进程之前询问。 + +实时交接是实验性的,需要明确选择。独立设置过程中需要替换受支持的服务器时,可为 `herdr --remote` 显式添加 `--handoff`;正常连接或认证修复不需要它。有关重启和交接的选择,请参阅[更新](/zh-cn/docs/install/)及[会话状态与恢复](/zh-cn/docs/session-state/)。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/how-to-work.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/how-to-work.mdx new file mode 100644 index 00000000..76d79cf5 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/how-to-work.mdx @@ -0,0 +1,103 @@ +--- +title: 使用 Herdr 的工作方式 +description: 在本地、SSH 内或通过远程连接运行 Herdr。 +--- + +在工作所在的地方运行 Herdr,从你所在的任何地方连接。 + +Herdr 由一个后台会话服务器和一个或多个终端客户端组成。窗格在服务器中持续运行。客户端负责连接、分离和渲染会话。 + +## 本地工作 + +在项目目录中启动 Herdr: + +```bash +herdr +``` + +Herdr 会自动启动或连接到本地后台会话。你不需要管理 socket。在窗格里照常运行 shell、服务器、测试和智能体。 + +用 `ctrl+b q` 分离客户端。窗格会继续运行。 + +之后重新连接: + +```bash +herdr +``` + +如果想结束会话并停止其中的窗格,停止服务器: + +```bash +herdr server stop +``` + +## 通过普通 SSH 的远程工作 + +SSH 到有代码和凭据的机器,然后在那里运行 Herdr: + +```bash +ssh you@server +herdr +``` + +这和终端复用器的用法一样。你的 shell 在远程,Herdr 服务器在远程,智能体和窗格都运行在远程机器上。用 `ctrl+b q` 分离并断开连接,之后再 SSH 回去,重新运行 `herdr`。 + +当你本来就工作在 SSH shell 里、在手机或平板的 SSH 客户端上,或者想要最简单的配置时,用这条路径。 + +## 用手机工作 + +你不需要 Herdr 手机应用或 Web 仪表盘。在手机上装任意一个 SSH 客户端,连到智能体所在的机器,在那里启动 Herdr: + +```bash +ssh you@server +herdr +``` + +同一个持久的 Herdr 会话就会在手机终端中打开。TUI 会适应窄屏,所以你可以在不离开 SSH 的情况下检查智能体、切换工作区、查看窗格。 + +在 iPhone 上,[moshi](https://getmoshi.app/) 这类应用表现很好。 + +<div class="mobile-doc-shots"> + <figure> + <img src="/assets/mobile-agent-session-v2.jpeg" alt="手机上通过 SSH 的 Herdr 智能体会话" loading="lazy" /> + <figcaption>SSH 上的智能体会话</figcaption> + </figure> + <figure> + <img src="/assets/mobile-switch-menu-v2.jpeg" alt="手机上 Herdr 的响应式切换菜单" loading="lazy" /> + <figcaption>响应式切换菜单</figcaption> + </figure> +</div> + +## 从本地终端远程工作 + +不先打开 shell,直接通过 SSH 连接: + +```bash +herdr --remote workbox +herdr --remote ssh://you@server:2222 +``` + +远程服务器保持窗格运行,并通过 SSH 发送终端内容和会话状态。本地 Herdr 使用本地主题和显示设置绘制 UI。 + +想让远程会话用起来像本地时,用这条路径。客户端运行在你的机器上,所以图像剪贴板粘贴等本地桌面功能可以桥接到远程服务器。如果你先 SSH 再在服务器上运行 `herdr`,Herdr 就完全运行在那台服务器上,无法读取你本地桌面的剪贴板。 + +对于经常连接的目标,把主机写进 SSH 配置: + +```text +Host workbox + HostName server.example.com + User you + Port 2222 +``` + +然后这样连接: + +```bash +herdr --remote workbox +``` + +## 该用哪条路径 + +本地工作用 `herdr`。想让 Herdr 在远程 shell 上表现得像 tmux,或者在用手机 SSH 客户端时,用 `ssh you@server` 再 `herdr`。需要通过本地 UI 操作一个远程会话并粘贴本地剪贴板图像时,使用 `herdr --remote <host>`。要在同一窗口使用 Local 和多台 SSH 机器,可通过 `herdr machine add <host> --label <label>` 保存目标;参见[连接机器](/zh-cn/docs/connecting-machines/)。 + +关于远程引导细节、命名远程会话、自定义二进制和直接终端附加,参见[持久化与远程访问](/zh-cn/docs/persistence-remote/)。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/index.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/index.mdx new file mode 100644 index 00000000..8e5e1b0f --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/index.mdx @@ -0,0 +1,82 @@ +--- +title: Herdr 文档 +description: 面向 AI 编程智能体的终端工作区管理器。 +template: splash +hero: + tagline: "安装、学习和配置 Herdr。从适合你的路径开始 — 无需任何终端复用器经验。" + image: + file: ../../../../public/assets/logo.svg + actions: + - text: 安装 Herdr + link: /zh-cn/docs/install/ + - text: 快速开始 + link: /zh-cn/docs/quick-start/ + variant: secondary +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + +## 选择你的路径 + +<CardGrid> + <Card title="第一次接触终端复用器?"> + 上手不需要学习快捷键。Herdr 以鼠标为先: 点击窗格、拖动边框、通过右键菜单分割和切换。 + + [快速开始 →](/zh-cn/docs/quick-start/) + </Card> + <Card title="从 tmux 或 zellij 迁移过来?"> + 这套模型你已经很熟悉了。前缀键是 `ctrl+b`,窗格会持久保留,分离和重新连接的行为都符合你的预期。 + + [核心概念 →](/zh-cn/docs/concepts/) · [按键绑定 →](/zh-cn/docs/configuration/#按键绑定) + </Card> +</CardGrid> + +## 或者让你的智能体来介绍 + +已经在用 AI 编程智能体?让它来帮你完成入门。粘贴这个提示词: + +```text +Help me understand and set up Herdr. Read https://herdr.dev/agent-guide.md first, then walk me through it step by step. +``` + +这份指南会教你的智能体 Herdr 的概念、安装、配置和常见问题的修复方法,让它的回答保持准确,而不是即兴发挥。 + +## 核心指南 + +<CardGrid> + <Card title="智能体"> + 了解受支持的智能体、检测行为、集成、自定义标签和直接附加。 + + [理解智能体 →](/zh-cn/docs/agents/) + </Card> + <Card title="连接机器"> + 在同一窗口使用 Local 和已保存的 SSH 机器,集中查看智能体,各连接独立重连。 + + [连接你的机器 →](/zh-cn/docs/connecting-machines/) + </Card> + <Card title="会话状态"> + 理解分离、重启恢复、窗格历史回放、智能体原生会话恢复和实时交接。 + + [对比状态路径 →](/zh-cn/docs/session-state/) + </Card> + <Card title="配置"> + 配置按键绑定、主题、侧边栏行为、通知、回滚缓冲和高级选项。 + + [配置 Herdr →](/zh-cn/docs/configuration/) + </Card> + <Card title="API"> + 通过 CLI 和本地 socket API,从脚本、工具和智能体控制 Herdr。 + + [阅读 API 指南 →](/zh-cn/docs/socket-api/) + </Card> + <Card title="插件"> + 编写本地可执行的工作流插件,带有清单声明的动作和事件钩子。 + + [编写插件 →](/zh-cn/docs/plugins/) + </Card> + <Card title="插件市场"> + 现在就可以从 GitHub 分享插件,为仓库打上主题标签,插件市场上线时即可收录。 + + [发布插件 →](/zh-cn/docs/marketplace/) + </Card> +</CardGrid> diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/install.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/install.mdx new file mode 100644 index 00000000..8faba506 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/install.mdx @@ -0,0 +1,154 @@ +--- +title: 安装 Herdr +description: 在 Linux、macOS 和 Windows 上安装、更新和验证 Herdr。 +--- + +Herdr 为 Linux、macOS 和 Windows 提供稳定通道二进制文件。Windows 支持已正式发布,并记录了平台专用的已知限制和持续修复。 + +## 安装 + +在 Linux 或 macOS 上运行: + +```bash +curl -fsSL https://herdr.dev/install.sh | sh +``` + +在 Windows 上运行: + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex" +``` + +如果端点安全软件阻止该无文件 PowerShell 命令,请打开命令提示符并运行: + +```cmd +curl.exe -fsSLo install.cmd https://herdr.dev/install.cmd && install.cmd && del install.cmd +``` + +安装器会下载适合你平台的发布二进制文件并放到 PATH 上。新的直接安装默认使用稳定更新通道。现有的 Windows 预览安装会继续留在预览通道,直到你运行 `herdr channel set stable`。在 Windows 上,PATH 指向当前带版本号的发布目录;安装器还会更新 `current` 和稳定的 `bin` 兼容别名,因此更新时不需要覆盖正在运行的 `herdr.exe`。 + +## 用 Homebrew 安装 + +如果你已经在用 Homebrew: + +```bash +brew install herdr +``` + +## 用 mise 安装 + +如果你已经在用 mise: + +```bash +mise use -g herdr +``` + +如果 mise 报 `herdr not found in mise tool registry`,请更新 mise 后重试。旧版 mise 早于 Herdr 的注册表条目;`mise use -g github:herdrdev/herdr` 可以作为临时兜底。 + +## 用 Nix 安装 + +如果你已经在用 Nix,Herdr 提供了一个从源码构建的 flake: + +```bash +nix run github:herdrdev/herdr/v0.x.y +nix build github:herdrdev/herdr/v0.x.y +nix profile install github:herdrdev/herdr/v0.x.y +``` + +把 `v0.x.y` 替换为最新的发布标签。省略标签可以跟踪 `master`,但常规安装建议使用发布标签。 + +flake 还提供一个开发 shell: + +```bash +nix develop github:herdrdev/herdr +``` + +更新时使用你安装 Herdr 的同一套 Nix 工作流。对于 profile 安装,列出 profile 条目并升级 Herdr 条目: + +```bash +nix profile list +nix profile upgrade <index-or-name> +``` + +如果 Herdr 是你自己 flake 的一个 input,更新该 input 并重建你的系统、Home Manager 或开发环境: + +```bash +nix flake update herdr +``` + +## 手动下载 + +也可以从 [GitHub releases](https://github.com/herdrdev/herdr/releases) 下载二进制文件。 + +选择匹配你系统的产物: + +| 系统 | 产物 | +| --- | --- | +| Linux x86_64 | `herdr-linux-x86_64` | +| Linux aarch64 | `herdr-linux-aarch64` | +| macOS Intel | `herdr-macos-x86_64` | +| macOS Apple silicon | `herdr-macos-aarch64` | +| Windows x86_64 | `herdr-windows-x86_64.zip` | + +在 Linux 或 macOS 上,赋予可执行权限并移动到 PATH 上的某个位置。 + +```bash +chmod +x herdr-linux-x86_64 +mv herdr-linux-x86_64 ~/.local/bin/herdr +``` + +### Windows 压缩包 + +稳定版发布和预览版预发布都会包含 `herdr-windows-x86_64.zip`。压缩包包含 `herdr.exe` 及其应用本地 ConPTY 运行时。请保留完整的解压目录,不要只复制 `herdr.exe`。 + +## 验证 + +启动 Herdr: + +```bash +herdr +``` + +如果 shell 找不到 `herdr`,重启终端,或检查安装目录是否在 PATH 上。 + +## 更新 + +Herdr 会检查新版本并在应用内通知你。也可以手动更新: + +```bash +herdr update +``` + +`herdr update` 适用于由 Herdr 自带安装器管理的安装。Homebrew、mise 和 Nix 安装请改用那些包管理器更新。 + +Linux、macOS 和 Windows 的直接安装默认使用稳定更新通道。要启用来自 `master` 的预览构建,设置通道: + +```bash +herdr channel set preview +``` + +用同样的方式把直接安装切回稳定版: + +```bash +herdr channel set stable +``` + +对于直接安装,切换通道时也会检查该通道并安装其最新二进制文件。如果该更新失败,运行 `herdr update` 从配置的通道重试。 + +预览构建是从当前开发分支定期发布的 GitHub 预发布。当你想在下一个稳定版之前拿到修复时很有用,但它们可能出现回归。Homebrew、mise 和 Nix 安装不使用预览通道。 + +Windows 的常规使用建议选择稳定版。预览版能更早获得修复,但可能出现回归,因此只有在接受这一取舍时才应主动启用。现有的 Windows 预览安装会继续留在预览通道,直到你明确切换。如果旧预览构建拒绝 `herdr channel set stable`,请先在预览通道运行一次 `herdr update`,再重试切换通道。 + +默认情况下,`herdr update` 会安装新二进制文件,并让端点第 1 代服务器及其窗格进程继续运行。再次启动 Herdr 即可用更新后的客户端重新连接。纯客户端改动会立即生效。新的服务器端操作会保持不可用,直到对应服务器更新并重启;客户端会显示通知,而不会断开连接。只有早于端点第 1 代的服务器需要在更新时再停止最后一次。 + +要对受支持的运行中会话启用实验性的实时服务器交接,运行: + +```bash +herdr update --handoff +``` + +实时交接不适用于 Homebrew、mise 或 Nix 的包管理器更新。这些安装先用包管理器更新,再重新运行 Herdr,用更新后的客户端连接。兼容的旧服务器会继续运行。只有需要新版本的服务器端改动时,才稍后用 `herdr server stop` 或 `herdr session stop <name>` 重启它。 + +## 系统要求 + +Linux、macOS 和 Windows x86_64 都提供稳定通道二进制文件。受支持的工作流和已知限制见 [Windows 支持](/zh-cn/docs/windows-beta/)。Windows ARM64 通过 Windows 模拟运行 x86_64 构建。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/integrations.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/integrations.mdx new file mode 100644 index 00000000..5eb1f322 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/integrations.mdx @@ -0,0 +1,400 @@ +--- +title: 集成 +description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 +--- + +Herdr 自动检测受支持的智能体。官方集成可以额外提供用于恢复的原生会话身份、生命周期状态上报,或两者兼有。 + +当你想要智能体原生会话恢复、直接生命周期上报,或两者都要时,使用集成。完整的状态权威模型见[智能体](/zh-cn/docs/agents/)。 + +## 安装集成 + +在 Herdr 中打开设置,用集成标签页为 `PATH` 上发现的智能体安装推荐集成,或手动运行命令: + +```bash +herdr integration install pi +herdr integration install omp +herdr integration install claude +herdr integration install codex +herdr integration install copilot +herdr integration install devin +herdr integration install droid +herdr integration install kimi +herdr integration install opencode +herdr integration install kilo +herdr integration install hermes +herdr integration install mastracode +herdr integration install qodercli +herdr integration install qwen +herdr integration install letta +herdr integration install cursor +herdr integration install antigravity-cli +herdr integration install grok +``` + +## 卸载集成 + +```bash +herdr integration uninstall pi +herdr integration uninstall omp +herdr integration uninstall claude +herdr integration uninstall codex +herdr integration uninstall copilot +herdr integration uninstall devin +herdr integration uninstall droid +herdr integration uninstall kimi +herdr integration uninstall opencode +herdr integration uninstall kilo +herdr integration uninstall hermes +herdr integration uninstall mastracode +herdr integration uninstall qodercli +herdr integration uninstall qwen +herdr integration uninstall letta +herdr integration uninstall cursor +herdr integration uninstall antigravity-cli +herdr integration uninstall grok +``` + +Herdr 对 Linux/macOS 上的集成配置和所有平台的新文件使用原子保存;Windows 上的现有文件先备份再原地更新,以保留权限。如果 Windows 提示存在备份,请在重试前关闭智能体并检查配置:未完成的 `.herdr-backup.pending` 只能在确认配置正确后删除,不能用于恢复;完整 `.herdr-backup` 的内容可在需要时写回现有文件(若文件已丢失,先设置其预期权限),确认配置正确后再删除备份。 + +## Herdr 如何使用集成 + +Herdr 以两种不同方式使用集成: + +| 集成类型 | 智能体 | 效果 | +| --- | --- | --- | +| 生命周期权威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | 已安装且在为该窗格主动上报时,由钩子或插件事件决定 `idle`、`working` 和 `blocked`。对同一个生命周期权威,Herdr 不再使用屏幕清单兜底。 | +| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | + +自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。 + +## 集成你自己的智能体 + +在 Herdr 窗格中运行的智能体会继承 `HERDR_ENV`、`HERDR_PANE_ID`、`HERDR_BIN_PATH` 和 `HERDR_SOCKET_PATH`。如果智能体提供生命周期钩子,可从这些钩子通过 Herdr CLI 上报语义状态: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source custom:my-agent \ + --agent my-agent \ + --state working +``` + +当智能体等待输入时上报 `idle`,需要用户决策时上报 `blocked`。用 `--message` 描述阻塞原因。智能体退出时,释放同一来源的生命周期权威: + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source custom:my-agent \ + --agent my-agent +``` + +仅在 `HERDR_ENV=1` 且必要变量存在时上报,这样集成在 Herdr 外不会执行任何操作。保持 `--source` 稳定且对该集成唯一。如果上报可能乱序到达,请加入严格递增的 `--seq`;Herdr 会忽略同一来源的旧序列号。 + +你可以在 `report-agent` 中加入 `--agent-session-id` 或 `--agent-session-path`,也可以在会话身份独立于状态变化时使用 `pane report-agent-session`。Herdr 会通过窗格和智能体 API 暴露该引用。自动恢复会话还要求 Herdr 知道如何启动该智能体并恢复所引用的会话。 + +要实现可移植的集成,请使用 `HERDR_BIN_PATH` 和 CLI 包装命令。需要直接 IPC 的代码可以发送 [Socket API](/zh-cn/docs/socket-api/) 中说明的等效 `pane.report_agent`、`pane.report_agent_session` 和 `pane.release_agent` 请求。 + +[Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实实现示例。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放权威。 + +一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 + +原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Letta Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `2`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 + +## Pi + +安装 Pi 集成: + +```bash +herdr integration install pi +``` + +Herdr 把内置扩展写入: + +```text +~/.pi/agent/extensions/herdr-agent-state.ts +``` + +如果设置了 `PI_CODING_AGENT_DIR`,Herdr 会改为写入 `$PI_CODING_AGENT_DIR/extensions/herdr-agent-state.ts`。如果 Pi agent 目录已经存在,Herdr 会创建 extensions 目录。卸载只删除那个扩展文件。 + +## OMP + +安装 OMP 集成: + +```bash +herdr integration install omp +``` + +Herdr 把内置扩展写入: + +```text +~/.omp/agent/extensions/herdr-omp-agent-state.ts +``` + +如果设置了 `PI_CODING_AGENT_DIR`,Herdr 会改为写入 `$PI_CODING_AGENT_DIR/extensions/herdr-omp-agent-state.ts`。如果 OMP agent 目录已经存在,Herdr 会创建 extensions 目录。卸载只删除那个扩展文件。 + +OMP 集成通过 Herdr 的 socket API 上报智能体标签 `omp`、生命周期状态和原生会话身份。它不需要对 `omp` 可执行文件做原生进程检测,并且 Herdr 可以在服务器重启后用 `omp --resume=<session>` 恢复 OMP 窗格。 + +## Claude Code + +安装 Claude Code 钩子: + +```bash +herdr integration install claude +``` + +该钩子在会话启动时把 Claude Code 的会话身份上报给本地 Herdr socket。Claude Code 的状态来自 Herdr 的屏幕清单检测。 + +Claude 集成 v10 仅匹配文档列出的 `SessionStart` 来源: `startup`、`resume`、`clear`、`compact` 和 `fork`。这会阻止 Grok 的 `new` 和 `load` 事件启动导入的 Herdr Claude 钩子,而不会禁用其他 Claude 兼容钩子。升级后请重新安装 Claude 集成,以迁移原有的通配匹配规则。Claude 新增来源值时需要更新集成。 + +Herdr 默认使用 `~/.claude`,设置了 `CLAUDE_CONFIG_DIR` 时使用后者。Claude 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`,并向 `settings.json` 添加 Herdr 钩子条目。卸载会移除匹配的钩子条目并删除钩子脚本。 + +## Codex + +安装 Codex 钩子: + +```bash +herdr integration install codex +``` + +Codex 钩子通过与其他集成相同的本地 socket API 上报会话身份。Codex 的状态来自 Herdr 的屏幕清单检测。 + +Herdr 默认使用 `~/.codex`,设置了 `CODEX_HOME` 时使用后者。Codex 配置目录必须已经存在。安装会写入 `herdr-agent-state.sh`、更新 `hooks.json`,并确保 `config.toml` 中有 `[features] hooks = true`。如果存在已弃用的顶层 `codex_hooks` 标志,它也会一并移除。卸载会从 `hooks.json` 中移除 Herdr 条目并删除钩子脚本,但不改动 `config.toml`。 + +## GitHub Copilot CLI + +安装 GitHub Copilot CLI 钩子: + +```bash +herdr integration install copilot +``` + +Copilot 钩子通过与其他集成相同的本地 socket API 上报会话身份。Copilot 的状态来自 Herdr 的屏幕清单检测。 + +Herdr 默认使用 `~/.copilot`,设置了 `COPILOT_HOME` 时使用后者。Copilot 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`,并向 `settings.json` 添加一个 `SessionStart` 钩子条目。卸载会从 `settings.json` 中移除 Herdr 条目并删除钩子脚本。 + +在 Copilot 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `copilot --resume=<id>` 恢复该窗格。 + +## Devin CLI + +安装 Devin CLI 钩子: + +```bash +herdr integration install devin +``` + +该钩子从 Devin 的会话、提示、工具使用、权限和停止事件中上报原生会话身份。Devin 的状态仍来自 Herdr 的屏幕清单和 OSC 检测,因为 Devin 钩子不会在每次权限取消或用户中断后都发出可靠的状态转换。 + +设置了 `XDG_CONFIG_HOME` 时,Herdr 使用 `$XDG_CONFIG_HOME/devin`。否则,在 Linux 和 macOS 上使用 `~/.config/devin`,在 Windows 上使用 `%APPDATA%\devin`。Devin 配置目录必须已经存在。安装在 Unix 上写入 `herdr-agent-state.sh`,在 Windows 上写入 `herdr-agent-state.ps1`,并向 `config.json` 添加 Herdr 钩子条目。钩子在 Devin 运行期间刷新会话引用。卸载会从 `config.json` 中移除 Herdr 条目并删除钩子脚本。 + +Herdr 用 `devin --resume <id>` 恢复保存的 Devin 会话。无论钩子是否安装,屏幕清单检测始终是状态权威。 + +## Kimi Code CLI + +安装 Kimi Code CLI 钩子: + +```bash +herdr integration install kimi +``` + +该钩子向 Herdr 上报 Kimi 的会话身份和生命周期状态,用于原生恢复和权威的 `idle`、`working`、`blocked` 状态。需要 Kimi Code CLI `0.14.0` 或更新版本。 + +Herdr 默认使用 `~/.kimi-code`,设置了 `KIMI_CODE_HOME` 时使用后者。Kimi Code 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`,并在 `config.toml` 中追加 Herdr 管理的 `[[hooks]]` 条目。卸载会移除 Herdr 管理的配置块并删除钩子脚本。 + +Herdr 用 `kimi --session <id>` 恢复保存的 Kimi 会话。 + +## Droid + +安装 Droid 钩子: + +```bash +herdr integration install droid +``` + +Droid 钩子通过与其他集成相同的本地 socket API 上报会话身份。生命周期状态仍来自 Herdr 的屏幕清单检测,因为 Droid 钩子没有覆盖所有生命周期转换。 + +Herdr 的 Droid 钩子使用 `~/.factory`。Factory 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`,向 `settings.json` 添加 Herdr 的 `SessionStart` 钩子条目,并在 `hooks.json` 中存在旧的 Herdr Droid 钩子条目时将其移除。卸载会从两个配置文件中移除 Herdr 条目并删除钩子脚本。 + +在 Droid 发出会话启动事件后,Herdr 可以用上报的会话 id 通过 `droid --resume <id>` 恢复该窗格。 + +## OpenCode + +安装 OpenCode 插件: + +```bash +herdr integration install opencode +``` + +该集成支持 OpenCode V1 `1.18.29` 及更高版本,以及 OpenCode V2(已验证 beta `19242`)。OpenCode 配置目录必须已经存在。Herdr 将服务端入口安装到 `~/.config/opencode/plugins/herdr-agent-state.js`,并在该配置目录安装共享 TUI 插件 `herdr-tui-session.js` 和 V2 TUI 入口 `herdr-opencode/tui.js`。 + +安装会在 `tui.jsonc` 中注册 V1 TUI 插件,并在 `cli.json` 中注册 V2 TUI 插件,同时保留其他设置和插件。如果 OpenCode 仍有 V1 TUI 偏好需要迁移(`tui.json` 或 `kv.json`),Herdr 会推迟注册,以便首次启动时执行迁移;此时请先启动一次 `opencode2`,然后重新安装集成。否则 Herdr 会创建 `cli.json` 并注册插件。安装后请重启 OpenCode TUI。卸载会删除受管理的插件文件及其配置条目。 + +V2 生命周期上报在窗格本地的 TUI 中运行。即使多个窗格共享同一个 OpenCode 服务端,事件也只归属于该 TUI 选中的根会话。完成和中断会清除工作状态;待处理的权限请求、表单以及执行失败会使窗格保持阻塞。V2 Mini 和无界面客户端不运行 TUI 插件,因此不提供此生命周期上报。 + +该插件在 OpenCode 运行于 Herdr 窗格内时上报生命周期状态和会话身份。在 OpenCode 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `opencode --session <id>` 恢复该窗格。插件未安装时,屏幕清单检测仍然可用。 + +## Kilo Code CLI + +安装 Kilo Code CLI 插件: + +```bash +herdr integration install kilo +``` + +Herdr 把插件写入 `~/.config/kilo/plugin/herdr-agent-state.js`。Kilo 配置目录必须已经存在。卸载只删除那个插件文件。 + +该插件在 Kilo 运行于 Herdr 窗格内时上报生命周期状态和会话身份。在 Kilo 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `kilo --session <id>` 恢复该窗格。插件未安装时,屏幕清单检测仍然可用。 + +## Hermes Agent + +安装 Hermes Agent 插件: + +```bash +herdr integration install hermes +``` + +Herdr 写入 Hermes 主目录下的 `plugins/herdr-agent-state/`,并在 Hermes 主目录的 `config.yaml` 中启用 `herdr-agent-state`。`HERMES_HOME` 在 Unix 上默认为 `~/.hermes`,在 Windows 上默认为 `%LOCALAPPDATA%\hermes`。Hermes 配置目录必须已经存在。安装后请重启 Hermes 以加载插件。卸载会删除插件目录,并从 `plugins.enabled` 中移除 `herdr-agent-state`。 + +该插件在 Hermes 运行于 Herdr 窗格内时上报可恢复的会话 id。Herdr 使用屏幕清单检测 `working`、`idle` 和 `blocked`,并可用上报的会话 id 通过 `hermes --resume <id>` 恢复该窗格。 + +## Qoder CLI + +安装 Qoder CLI 钩子: + +```bash +herdr integration install qodercli +``` + +该钩子向 Herdr 上报 Qoder CLI 的会话身份,用于原生恢复。生命周期状态仍来自 Herdr 的屏幕清单检测,因为 Qoder 钩子没有覆盖所有生命周期转换。 + +Herdr 默认使用 `~/.qoder`,设置了 `QODER_CONFIG_DIR` 时使用后者。Qoder 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`,并向 `settings.json` 添加 Herdr 钩子条目。卸载会移除匹配的钩子条目并删除钩子脚本。 + +Herdr 用 `qodercli --resume <id>` 恢复保存的 Qoder CLI 会话。 + +钩子未安装时,屏幕清单检测仍然可用。 + +## Qwen Code + +安装 Qwen Code 钩子: + +```bash +herdr integration install qwen +``` + +`SessionStart` 钩子只上报用于原生恢复的会话身份。生命周期状态仍由 Herdr 的屏幕清单检测决定。 + +Herdr 默认使用 `~/.qwen`,设置了 `QWEN_HOME` 时使用后者。Qwen 配置目录必须已经存在。安装会写入 `hooks/herdr-agent-session.sh`(Windows 上为 `hooks/herdr-agent-session.ps1`),并向 `settings.json` 添加 Herdr 条目。卸载只移除匹配条目和托管脚本。 + +Herdr 用 `qwen --resume <id>` 恢复保存的 Qwen Code 会话。 + +## Letta Code + +Letta Code 集成安装是实验性的 CLI 专用功能,不在 Settings 的集成列表或 JSON 集成 API 中提供。`herdr integration status` 将其显示为 `letta (experimental)`。智能体检测和 `herdr agent start --kind letta` 不受此安装方式限制,均已支持。 + +安装 Letta Code 钩子: + +```bash +herdr integration install letta +``` + +静默的 `SessionStart` 钩子会上报会话 ID,用于原生恢复。Letta Code 状态仍来自 Herdr 的屏幕清单检测。 + +Herdr 使用 `~/.letta`。配置目录必须已经存在。安装会写入 `hooks/herdr-agent-session.sh`(Windows 上为 `hooks/herdr-agent-session.ps1`),并向 `settings.json` 添加 Herdr 条目。卸载只移除匹配条目和托管脚本。 + +Herdr 用 `letta --conversation <id>` 恢复命名会话。它把默认会话记录为 `default:<agent-id>`,并用 `letta --conversation default --agent <agent-id>` 恢复该会话。 + +## Cursor Agent CLI + +安装 Cursor Agent CLI 钩子: + +```bash +herdr integration install cursor +``` + +该钩子在 Cursor Agent CLI 运行于 Herdr 窗格内时,通过 Cursor 的 `sessionStart` 钩子上报会话身份。Cursor 的状态来自 Herdr 的屏幕清单检测。 + +Herdr 默认使用 `~/.cursor`,设置了 `CURSOR_CONFIG_DIR` 时使用后者。Cursor 配置目录必须已经存在。安装会写入 `herdr-agent-state.sh`(Windows 上为 `herdr-agent-state.ps1`),并向 `hooks.json` 添加 Herdr 的 `sessionStart` 条目。卸载会移除匹配的钩子条目并删除钩子脚本。 + +在 Cursor 发出会话启动事件后,Herdr 可以用上报的会话 id 通过 `cursor-agent --resume <id>` 恢复该窗格。Herdr 恢复窗格时,`cursor-agent` 命令必须在 `PATH` 上;Herdr 不会启动通用的 `agent` 命令。 + +## MastraCode + +安装 MastraCode 钩子: + +```bash +herdr integration install mastracode +``` + +该钩子向 Herdr 上报 MastraCode 生命周期状态和线程身份,用于权威的 `idle`、`working`、`blocked` 状态和原生恢复。MastraCode 没有屏幕清单兜底;当 MastraCode 在 Herdr 窗格内运行时,状态来自该钩子。 + +Herdr 使用 `~/.mastracode`。安装会写入 `hooks/herdr-agent-state.sh`(Windows 上为 `hooks/herdr-agent-state.ps1`),并把 Herdr 命令条目添加到 `hooks.json`;目录不存在时会创建。卸载会删除匹配的钩子条目和钩子脚本。 + +Herdr 用 `mastracode --thread <id>` 恢复保存的 MastraCode 线程。 + +## Antigravity CLI + +安装 Antigravity CLI 钩子: + +```bash +herdr integration install antigravity-cli +``` + +Herdr 默认使用 `~/.gemini/config/`,设置了 `ANTIGRAVITY_CLI_CONFIG_DIR` 时使用后者。这是 Antigravity CLI 读取全局自定义配置的目录,并且必须已经存在。安装会写入 `hooks/herdr-agent-state.sh`(Windows 上为 `herdr-agent-state.ps1`),并向 `hooks.json` 添加由 Herdr 管理的 `herdr` 块。Antigravity CLI 按钩子名称管理 `hooks.json`,因此安装只会重写该块,不会改动其他命名钩子。卸载会移除 `herdr` 块并删除钩子脚本。 + +该集成仅上报会话。它会上报窗格当前运行的对话,但不上报智能体状态,因此 Herdr 仍根据 Antigravity CLI 在屏幕上绘制的内容判断 `working`、`idle` 和 `blocked`。 + +钩子在 `PreInvocation` 时运行,所以 Herdr 会在发送第一个提示词后获知对话。此后,Herdr 服务器重启时可以用 `agy --conversation <id>` 恢复窗格。 + +## Grok CLI + +安装 Grok CLI 钩子: + +```bash +herdr integration install grok +``` + +该钩子在 Grok CLI 运行于 Herdr 窗格内时,通过 Grok 的 `SessionStart` 钩子上报会话身份。Grok 的状态来自 Herdr 的屏幕清单检测。 + +Herdr 默认使用 `~/.grok`,设置了 `GROK_HOME` 时使用后者。Grok 配置目录必须已经存在。Grok 会合并该目录中的所有 `hooks/*.json` 文件,因此安装会写入独立的 `hooks/herdr.json`,其中包含 Herdr 的 `SessionStart` 条目,并同时写入 `hooks/herdr-agent-state.sh`(Windows 上为 `hooks/herdr-agent-state.ps1`);它不会修改其他钩子文件。卸载只会删除这两个由 Herdr 管理的文件。 + +Grok 发出会话启动事件后,Herdr 可以用上报的会话 id 通过 `grok --resume <id>` 恢复窗格。 + +## 自定义状态标签 + +集成只把生命周期状态作为语义状态上报。例如,上报智能体为 `working` 时,不在生命周期报告中添加展示字段。 + +```bash +herdr pane report-agent w1:p1 \ + --source custom:docs \ + --agent docs-bot \ + --state working +``` + +与 Herdr 管理的集成并行运行的用户钩子,应该使用元数据而不是 `report-agent`。元数据只改变展示,不会夺走集成对 `idle`、`working`、`blocked` 或会话恢复的权威。`--agent` 和 `--applies-to-source` 只守卫展示字段 (`--title`、`--display-agent` 和 `--state-label`)。令牌补丁始终生效;上报方负责清除或刷新 TTL。`--display-agent` 修改显示名称。 + +```bash +herdr pane report-metadata "$HERDR_PANE_ID" \ + --source user:claude-title \ + --agent claude \ + --title "Refactor auth middleware" \ + --display-agent "Claude: auth" \ + --token summary="refactor auth" \ + --state-label working="refactoring auth" \ + --ttl-ms 3600000 +``` + +令牌和状态标签只影响视觉。等待、通知和工作区汇总仍使用语义状态。 + +## 调试集成状态 + +列出已知的智能体: + +```bash +herdr agent list +``` + +需要验证 Herdr 能看到什么时,读取窗格: + +```bash +herdr pane read w1:p1 --source recent --lines 50 +``` + +如果集成状态看起来不对,先确认智能体运行在 Herdr 内部,并且相关的钩子或插件是为同一个用户账号安装的。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/keyboard.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/keyboard.mdx new file mode 100644 index 00000000..f1472966 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/keyboard.mdx @@ -0,0 +1,132 @@ +--- +title: 键盘 +description: 前缀键是什么、应该先学哪些绑定,以及如何做到不用前缀键。 +--- + +:::tip[从 tmux 或 zellij 迁移过来?] +这套模型你已经很熟悉了。直接跳到[按键绑定参考](/zh-cn/docs/configuration/#按键绑定)查看完整的默认键位和配置语法。 +::: + +Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智能体,拖动分割边框,使用右键菜单,一个按键绑定都不用学。键盘操作是可选的一层,不是必需项。 + +## 前缀键是什么 + +终端复用器位于终端和其中运行的程序之间。这些程序已经占用了大多数按键组合: `ctrl+c` 中断,`ctrl+r` 搜索历史,编辑器几乎占用了剩下的一切。如果 Herdr 直接抢占常用按键,就会破坏其中运行的程序。 + +前缀键解决了这个问题。按下前缀键 (默认 `ctrl+b`),下一次按键会发给 Herdr 而不是终端。`prefix+c` 的意思是: 按下 `ctrl+b`,松开,再按 `c`。只保留一个键,而不是几十个。 + +随时按 `prefix+?` 可以查看所有生效的绑定。在快捷键帮助中按 `/` 即可筛选操作和快捷键;使用 Backspace 编辑筛选内容,或按 `ctrl+u` 清除。 + +## 先学这五个 + +| 动作 | 按键 | +| --- | --- | +| 新建标签页 | `prefix+c` | +| 向右 / 向下分割 | `prefix+v` / `prefix+minus` | +| 在窗格间移动 | `prefix+h/j/k/l` | +| 工作区导航 | `prefix+w` | +| 分离并让一切继续运行 | `prefix+q` | + +这些覆盖了日常的大部分移动操作。其余的都可以继续用鼠标。 + +## 其余的,按任务分 + +窗格: + +| 动作 | 按键 | +| --- | --- | +| 缩放聚焦的窗格 | `prefix+z` | +| 关闭窗格 | `prefix+x` | +| 交换窗格 | `prefix+shift+h/j/k/l` | +| 调整大小模式 | `prefix+r` | +| 复制模式 | `prefix+[` | + +标签页: + +| 动作 | 按键 | +| --- | --- | +| 下一个 / 上一个标签页 | `prefix+n` / `prefix+p` | +| 跳转到标签页 1–9 | `prefix+1..9` | +| 重命名标签页 | `prefix+shift+t` | +| 关闭标签页 | `prefix+shift+x` | + +工作区与会话: + +| 动作 | 按键 | +| --- | --- | +| 新建工作区 | `prefix+shift+n` | +| 重命名工作区 | `prefix+shift+w` | +| 关闭工作区 | `prefix+shift+d` | +| Goto 选择器 | `prefix+g` | +| 切换侧边栏 | `prefix+b` | + +完整键位和绑定语法见[按键绑定参考](/zh-cn/docs/configuration/#按键绑定)。 + +## 编辑文本输入框 + +Herdr 的命名对话框、worktree 分支名、筛选框和复制模式搜索框支持在光标处编辑。以下快捷键适用于 Herdr 自己的输入框,不改变窗格内 shell 或智能体的按键行为。 + +| 操作 | 按键 | +| --- | --- | +| 移动一个字符 | Left / Right、Ctrl+B / Ctrl+F | +| 移到开头 / 末尾 | Home / End、Ctrl+A / Ctrl+E | +| 移动一个词 | Alt+B / Alt+F | +| 删除前一个字符 | Backspace、Ctrl+H | +| 删除后一个字符 | Delete、Ctrl+D | +| 剪切到开头 / 末尾 | Ctrl+U / Ctrl+K | +| 剪切前一个词 | Ctrl+W、Alt+Backspace、Ctrl+Backspace | +| 剪切后一个词 | Alt+D | +| 插入上次剪切的文本 | Ctrl+Y | + +输入和粘贴会插入光标所在位置。移动和删除遵循 Unicode 字素边界,长内容会水平滚动。剪切的文本只保留在当前输入框中,不使用系统剪贴板。Alt 快捷键需要终端报告 Alt/Meta。Enter 和 Esc 仍用于对话框的确认和取消。 + +## 复制模式 + +按 `prefix+[` 让聚焦的窗格进入复制模式。用 `h/j/k/l`、tmux 风格的 `w/b/e` 和 `{`/`}` 移动。按 `/` 或 `?` 向前或向后进行文本搜索,再用 `n` 或 `N` 按相同或相反方向重复搜索。查询包含大写字母时区分大小写,否则不区分。用 `v` 或空格开始选择,用 `y` 或回车复制,用 `q` 或 Esc 不复制直接退出。Esc 会先清除当前选择或搜索,然后才退出。复制模式不会暂停窗格进程:停留在底部时继续跟随输出,进入历史记录后保持当前位置。鼠标拖选可以直接复制,完全不用进入复制模式。 + +鼠标拖选以及通过 `v`、空格或 `V` 开始的选择会在输出和重绘期间保持有效,即使部分选中范围位于可视区域之外。复制读取的是该范围内当前的文本,而不是冻结的快照。调整窗格大小或在普通屏幕与备用屏幕之间切换仍会清除选择。搜索结果和双击单词选择仍保留内容变化检查。 + +## 一切都可以改 + +每个绑定都可以配置,包括前缀键本身: + +```toml +[keys] +prefix = "ctrl+a" +``` + +## 不用前缀键 + +你可以把 Herdr 动作绑定到完全不需要前缀的直接组合键。难点在于知道哪些组合键是安全的,因为终端、shell 和桌面环境已经占用了键盘的大部分。 + +任何组合键都可以做绑定: `ctrl+j`、`alt+k`,顺手就行。但一个组合键要经过三层才能到达 Herdr: 操作系统、外层终端 (Ghostty、iTerm2 等都带有自己的默认键位),以及窗格内运行的程序。`ctrl+j` 能顺利到达 Herdr,但 shell 和编辑器把它当作回车。`alt+k` 在 Linux 上是空闲的,但在 macOS 的大多数终端里会被组合成特殊字符。如果你从这些键系里选组合键,请对照自己的终端和系统快捷键再确认一遍。 + +我们梳理了 Ghostty、iTerm2、Terminal.app、kitty、WezTerm、Alacritty、Warp、Windows Terminal、GNOME Terminal 和 Konsole 的默认按键绑定,以及 GNOME 和 KDE 的全局快捷键。有一个修饰键系几乎在所有地方都没被占用: `ctrl+alt`。终端把它留空,它不受 macOS option 键组合行为 (会挡掉纯 `alt` 组合键) 的影响,而且即使在没有现代键盘协议的终端里也能正常传输。它是安全的默认推荐;选择权在你。 + +下面的配置保留前缀绑定,并在其上叠加直接组合键: + +```toml +[keys] +focus_pane_left = ["prefix+h", "ctrl+alt+h"] +focus_pane_down = ["prefix+j", "ctrl+alt+j"] +focus_pane_up = ["prefix+k", "ctrl+alt+k"] +focus_pane_right = ["prefix+l", "ctrl+alt+l"] +previous_tab = ["prefix+p", "ctrl+alt+["] +next_tab = ["prefix+n", "ctrl+alt+]"] +new_tab = ["prefix+c", "ctrl+alt+c"] +split_vertical = ["prefix+v", "ctrl+alt+d"] +split_horizontal = ["prefix+minus", "ctrl+alt+shift+d"] +zoom = ["prefix+z", "ctrl+alt+z"] +``` + +少数 `ctrl+alt` 组合键已被其他地方占用。请避开这些: + +| 组合键 | 占用者 | +| --- | --- | +| `ctrl+alt+arrows` | GNOME 工作区切换,Ghostty 和 Konsole 默认键位 | +| `ctrl+alt+t` | Ubuntu 和 Fedora 的“启动终端” | +| `ctrl+alt+l` / `ctrl+alt+a` | KDE 锁屏 / 注意力窗口 | +| `ctrl+alt+s` / `ctrl+alt+u` | Konsole | +| `ctrl+alt+f1..f12` | Linux 虚拟控制台切换 | + +如果某个直接组合键没有任何反应,说明终端或桌面环境在 Herdr 看到它之前就把它消费掉了。两边任选其一重新配置: 在终端设置里释放这个组合键,或者在 Herdr 里换一个。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/marketplace.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/marketplace.mdx new file mode 100644 index 00000000..246048c7 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/marketplace.mdx @@ -0,0 +1,44 @@ +--- +title: 插件市场 +description: 在 GitHub 上发现社区 Herdr 插件,并让你自己的插件被收录。 +--- + +Herdr 插件市场是一个可供发现的社区插件索引。 +访问 [herdr.dev/plugins](/plugins/) 浏览。它是公开 GitHub 仓库的自动索引, +而不是经过审核的目录。 + +## 浏览插件 + +[插件市场](/plugins/)会列出带有 GitHub 主题标签 `herdr-plugin`,并且默认分支中 +至少包含一个必需元数据可解析的 `herdr-plugin.toml` 的公开仓库。你可以搜索仓库和 +插件元数据,并按热度、最近活跃度或最新排序仓库卡片。每张卡片链接到源码仓库, +并列出其中发现的所有插件。 + +收录是自动且未经审核的。被列出只说明仓库给自己打了标签,并不代表 Herdr +审查过它,所以在安装任何插件之前,请先阅读[信任指南](/zh-cn/docs/plugins/#信任与安全)。 + +## 安装插件 + +插件市场是在安装之上增加的发现渠道,它不会取代安装。任何插件都可以直接从 +GitHub 安装: + +```bash +herdr plugin install owner/repo[/subdir...] +``` + +发布一个普通的公开 GitHub 仓库,在根目录或子目录放一个 `herdr-plugin.toml` +清单,这条命令就能用了。清单和编写参考见[插件](/zh-cn/docs/plugins/)。 + +## 让你的插件被收录 + +给公开仓库添加 GitHub 主题标签 `herdr-plugin`,并在默认分支中放置一个或多个 +必需元数据可解析的 `herdr-plugin.toml`。清单可以位于根目录或子目录。插件市场 +每个仓库显示一张卡片,并把每份有效清单列为可单独安装的插件。索引每 30 分钟 +自动刷新。 + +## 条目会展示什么 + +每张卡片展示 GitHub 仓库的名称、作者、描述、star 数、主要语言和最后 push 时间。 +插件行展示每份清单的 `name` 和 `version`,并链接到精确的源码目录。索引还会记录 +清单路径、`id`、`name`、`version`、`platforms`、`min_herdr_version`,以及默认分支的 +精确提交。Fork、已归档仓库、没有有效插件清单的仓库以及无效清单元数据都会被排除。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/persistence-remote.mdx new file mode 100644 index 00000000..ddd0b0fb --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -0,0 +1,151 @@ +--- +title: 持久化与远程访问 +description: 从 Herdr 分离、稍后重新连接、使用命名会话,以及通过 SSH 连接。 +--- + +Herdr 让窗格在后台服务器中持续运行。你的终端客户端可以分离,稍后再重新连接。 + +关于本地、SSH 和 `herdr --remote` 工作流,参见[使用 Herdr 的工作方式](/zh-cn/docs/how-to-work/)。 + +## 分离与重新连接 + +用 `ctrl+b q` 分离客户端;窗格和智能体继续运行。再次运行 `herdr` 即可重新连接。用 `herdr server stop` 停止会话及其窗格。 + +在服务器完全停止后再次启动时,Herdr 会恢复保存的会话形态。关于分离、服务器重启、屏幕历史回放、智能体原生会话恢复和实时交接各自能保留什么,参见[会话状态与恢复](/zh-cn/docs/session-state/)。 + +## 命名会话 + +需要相互独立的 Herdr 服务器时,使用命名会话。 + +```bash +herdr session list +herdr session attach work +herdr session attach side-project +herdr session stop work +herdr session delete side-project +``` + +命名会话拥有自己的窗格、标签页、工作区、socket 和运行时状态。它仍然共享同一个全局配置文件。 + +删除会话时,请使用 `herdr session list` 显示的完整名称,大小写必须一致。在不区分大小写的文件系统上,`herdr session delete foo` 会拒绝删除名为 `Foo` 的会话。 + +脚本中使用 `--json`: + +```bash +herdr session list --json +herdr session stop work --json +herdr session delete side-project --json +``` + +## 已保存的 SSH 机器 + +要在一个 Herdr 窗口中使用 Local 和多台 SSH 机器,请参阅[连接机器](/zh-cn/docs/connecting-machines/)。该指南涵盖设置、切换、重连、配置和远程自动化。移除已保存的机器只会断开客户端,不会停止远程会话。 + +## 通过 SSH 远程连接 + +[使用 Herdr 的工作方式](/zh-cn/docs/how-to-work/)比较了几种远程连接方式:先 SSH 到服务器再运行 `herdr`,使用已保存的 SSH 机器集中管理多个目标,或从本地直接连接一个远程会话: + +```bash +herdr --remote workbox +herdr --remote ssh://you@server:2222 +``` + +这种模式下,远程服务器保持窗格运行,并通过 SSH 发送终端内容和会话状态。本地 Herdr 负责绘制 UI,包括侧边栏、菜单和主题。因为客户端在本地运行,Herdr 可以把图像剪贴板粘贴等本地桌面功能桥接到远程会话: 把图像复制到远程临时文件,再粘贴该路径。 + +默认情况下,`herdr --remote` 在这次连接中使用你本地的 Herdr 按键绑定。即使远程服务器的配置不同,也能保持本地的肌肉记忆。编辑本地按键绑定后,可通过 UI 中的 `reload config` 应用更改,无需分离重连。想改用远程服务器配置时,使用 `--remote-keybindings server`。本地的自定义命令按键绑定不会被发送,因为那些命令会在远程主机上执行。 + +对于经常连接的目标,使用你的 SSH 配置: + +```text +Host workbox + HostName server.example.com + User you + Port 2222 +``` + +然后这样连接: + +```bash +herdr --remote workbox +``` + +远程连接和已保存的 SSH 机器支持 Linux、macOS 和 Windows 客户端,目标可以是 x86_64 或 aarch64 的 Linux/macOS 主机,也可以是 x86_64 的 Windows 主机。只要两端支持稳定端点代数,版本就不必一致。Linux/macOS 上优先使用远程 `PATH` 中兼容的 `herdr`,再检查常见的直接安装、Homebrew、mise 和 Nix profile 路径。Windows 上检查 `PATH` 和当前有效的托管版本。如果没有兼容的二进制文件,交互式运行会询问是否安装或更新;非交互式运行直接失败,不修改主机。Linux/macOS 安装到 `~/.local/bin/herdr`,Windows 安装包含应用本地 ConPTY 运行时的完整软件包。如果 `~/.local/bin` 不在远程 `PATH` 中,Herdr 会发出警告。 + +默认情况下,`herdr --remote` 通过一个临时 SSH 配置运行远程设置和桥接: 先包含你的 SSH 配置,再补充兜底的保活设置。已有的用户保活设置优先。Linux 和 macOS 客户端还会添加一个私有的单次连接 control socket 来复用连接;Windows OpenSSH 不使用此方式。设置 `[remote].manage_ssh_config = false` 可以不用 Herdr 生成的配置或 control socket,而使用普通 `ssh`。 + +远程连接使用正常的 OpenSSH 认证。如果在非交互式 shell、脚本、CI 或移动终端中无法输入私钥口令,请先将密钥加载到 ssh-agent: + +```bash +ssh-add +herdr --remote workbox +``` + +认证失败时,先通过 `ssh workbox` 确认普通 SSH 连接正常。 + +仅有版本差异不会替换或重启运行中的远程服务器。缺少必要兼容性或独立后台运行支持的服务器,会在停止前询问。已保存机器还需要画面传输和健康探测能力,详见[连接机器](/zh-cn/docs/connecting-machines/)。默认回答为 No;停止服务器会结束窗格进程。进行这次一次性升级时,可对受支持的服务器加上 `--handoff`,通过实验性实时交接保留窗格进程: + +```bash +herdr --remote workbox --handoff +``` + +如果你先 SSH 到服务器再在那里运行 `herdr`,Herdr 就完全运行在服务器上。这种模式简单实用,但除了普通的终端文本粘贴之外,它无法访问你本地桌面的剪贴板。 + +对于 Linux/macOS 主机,平台一致的直接安装可以复制当前本地二进制文件。Homebrew、mise、Nix 安装、平台不一致或目标为 Windows 时,会从 `https://herdr.dev/latest.json` 下载与当前客户端版本对应的发布产物。 + +对于本地构建或自定义软件包,在远程连接前设置 `HERDR_REMOTE_BINARY` 指向本地文件。Linux/macOS 目标使用 Herdr 可执行文件,Windows 目标必须使用完整的 `herdr-windows-x86_64.zip` 软件包。 + +```bash +HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox +``` + +## 远程命名会话 + +用 `--session` 搭配 `--remote` 连接远程主机上的命名会话: + +```bash +herdr --remote workbox --session agents +``` + +## 直接终端附加 + +完整的 Herdr 连接会打开整个工作区 UI。直接附加则在你当前的终端中打开一个由服务器拥有的终端。 + +直接终端附加适用于 Linux 和 macOS,不支持原生 Windows。 + +按智能体目标附加: + +```bash +herdr agent attach reviewer +``` + +按终端 ID 附加: + +```bash +herdr terminal attach term_abc123 +``` + +直接附加先流式传输当前渲染的终端状态,然后是实时 ANSI 帧。输入直接进入该终端。 + +用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 + +一个终端只能有一个可写的直接附加客户端拥有输入和调整尺寸的权限。用 `--takeover` 替换现有的所有者: + +```bash +herdr terminal attach term_abc123 --takeover +``` + +对于只需要读取已渲染终端数据的第三方桥接,可以使用只读观察者: + +```bash +herdr terminal session observe w1:p1 --cols 120 --rows 40 +``` + +它以逐行 JSON 输出包含 base64 ANSI 数据的 `terminal.frame`,服务器关闭流时输出 `terminal.closed`。多个观察者可以同时查看同一终端,不会取得输入、调整尺寸、滚动或接管权限。 + +交互式桥接可使用可写控制器: + +```bash +herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 +``` + +控制模式输出相同的帧,并从标准输入读取逐行 JSON 命令。`terminal.input` 发送文本或 base64 数据,`terminal.resize` 更改视口尺寸,`terminal.scroll` 滚动视口,`terminal.release` 关闭控制器。同一时间只有一个控制器能拥有输入和调整尺寸的权限。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/plugins.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/plugins.mdx new file mode 100644 index 00000000..67c606f0 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/plugins.mdx @@ -0,0 +1,330 @@ +--- +title: 插件 +description: 编写带有清单动作、事件钩子和窗格的本地 Herdr 插件。 +--- + +Herdr 插件是可分享、可执行的工作流包。插件可以是 Bash 脚本、JavaScript +应用、Lua 脚本、Rust 二进制,或你机器上能运行的任何 argv 命令。Herdr +负责宿主侧: 安装、清单校验、按键绑定、终端窗格、事件、调用上下文和 +socket 访问。插件负责自己的实现语言、依赖、文件和持久状态。 + +插件的存在是为了让 Herdr 保持精简。核心继续专注于终端工作区、窗格、 +智能体和稳定的 CLI/socket API。插件把这套已有的扩展面变成可复用的 +工作流,让大家可以构建、安装和分享,而不必把每种工作流都塞进 Herdr 本体。 + +插件不是 SDK 集成。它是一个带 `herdr-plugin.toml` 清单和 Herdr 可启动 +命令的目录。Herdr 校验清单、注入运行时上下文、启动声明的命令并记录日志。 +命令在需要做更多工作时,通过 CLI 或 socket 回调 Herdr。 + +没有单独的插件 SDK,也没有受限的命令集。整个 Herdr CLI 就是插件 API: +[CLI 参考](/zh-cn/docs/cli-reference/)中的每条命令插件都能用,你自己能以 +`herdr ...` 运行的任何东西,插件也能运行。大多数插件应通过指向运行中 +Herdr 二进制的 `HERDR_BIN_PATH` 调用 Herdr,这样插件在 Unix socket 和 +Windows 命名管道之间保持可移植。想自己发送原始 JSON 请求时,使用 +[socket API](/zh-cn/docs/socket-api/)。 + +运行时动作注册和非终端的原生插件 UI 不在插件 v1 范围内。动作、事件钩子、 +窗格和链接处理器都在清单中声明。 + +## 信任与安全 + +插件是运行在你机器上的普通代码。安装或链接一个插件时,它的构建和运行时 +命令以你的用户身份、在你的环境中执行,并且可以调用完整的 Herdr CLI — +和你给编辑器、shell 或编程智能体添加的任何扩展一样。这种开放性正是设计 +初衷,加上一点判断力就能保持安全。 + +从你信任的作者和仓库安装插件,并先大致看看新插件做什么: `herdr-plugin.toml` +清单,以及它运行的脚本或二进制。`herdr plugin install` 在交互式终端中会 +展示来源和将要运行的命令的预览,你可以在确认前审查。对已经信任的来源用 +`--yes`,想固定某个特定版本时用 `--ref`。 + +Herdr 校验清单,并把每个插件的配置和状态放在各自的目录中,但它不会审查 +或沙箱化插件的行为。第三方插件来自它们的作者,而不是 Herdr,审查与运行 +由你自行决定。 + +## 清单 + +清单是 Herdr 和插件之间的契约。它声明包元数据、支持的平台、可选的构建 +命令,以及 Herdr 可以运行的入口点。 + +```toml +id = "example.layout" +name = "Layout" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Apply project layouts" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["npm", "ci"] + +[[build]] +command = ["npm", "run", "build"] +platforms = ["linux", "macos"] + +[[startup]] +command = ["node", "dist/restore.js"] + +[[actions]] +id = "apply" +title = "Apply layout" +contexts = ["workspace"] +command = ["node", "dist/apply.js"] + +[[events]] +on = "worktree.created" +command = ["herdr", "workspace", "list"] + +[[panes]] +id = "board" +title = "Project board" +placement = "overlay" +command = ["herdr-board"] + +[[link_handlers]] +id = "github-issue" +title = "Open GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "apply" +``` + +顶层的 `id`、`name`、`version` 和 `min_herdr_version` 是必填项。 +把 `min_herdr_version` 设为支持你插件所用的插件 API、事件名和清单字段的 +最老 Herdr 版本。当插件的最低版本比当前二进制更新时,Herdr 会拒绝链接 +或安装。`description` 可选。插件 id 可以使用 ASCII 字母、数字、点、 +冒号、下划线和连字符。 + +动作 id、窗格 id 和链接处理器 id 是插件内部的本地 id。它们可以使用 +ASCII 字母、数字、冒号、下划线和连字符,但不能用点。每种 id 在插件内 +必须唯一。当需要全局唯一名称时,Herdr 会把动作 id 限定为 +`plugin.id.action` 的形式。 + +用 `platforms = ["linux", "macos", "windows"]` 声明插件可以运行的平台。 +构建命令、启动钩子、动作、事件钩子、窗格和链接处理器也可以声明自己的 +`platforms`;条目级的 platforms 覆盖顶层列表。没有顶层 `platforms` 的本地 +插件在链接时会给出警告。 + +`command` 的值是 argv 数组。Herdr 不会通过 shell 运行它们,所以除非你的 +命令自己启动 shell,否则没有 shell 展开。语言相关的行为放到你的脚本或 +二进制里。 + +## 第一个插件 + +从一个包含 `herdr-plugin.toml` 和一个可执行脚本或程序的目录开始: + +```text +my-plugin/ + herdr-plugin.toml + index.js +``` + +```toml +id = "example.workspace-tools" +name = "Workspace Tools" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Small workspace helpers" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "list-workspaces" +title = "List workspaces" +contexts = ["workspace"] +command = ["node", "index.js"] +``` + +在命令内部通过 `HERDR_BIN_PATH` 回调 Herdr: + +```js +const { spawnSync } = require("node:child_process"); + +const herdr = process.env.HERDR_BIN_PATH ?? "herdr"; +const result = spawnSync(herdr, ["workspace", "list"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}); + +process.stdout.write(result.stdout); +process.stderr.write(result.stderr); +process.exit(result.status ?? 1); +``` + +这个示例用了 Node,但插件本身完全不要求 Node。清单可以启动 Bash、 +PowerShell、Python、Rust、Go、Lua、Bun 或用户机器上可用的任何其他命令。 + +## 安装与链接 + +安装一个示例插件: + +```bash +herdr plugin install ogulcancelik/herdr-plugin-examples/agent-telegram-notify +herdr plugin config-dir examples.agent-telegram-notify +herdr plugin list +herdr plugin action list --plugin examples.agent-telegram-notify +``` + +在本地编写插件时,改为链接工作目录: + +```bash +herdr plugin link /path/to/plugin +herdr plugin config-dir example.layout +herdr plugin action list --plugin example.layout +herdr plugin action invoke example.layout.apply +herdr plugin pane open --plugin example.layout --entrypoint board +herdr plugin log list --plugin example.layout +``` + +`plugin install` 只接受 GitHub 简写,比如 `owner/repo/subdir`。它用 `git` +克隆,在交互式终端展示预览,运行受支持的构建命令,然后把检出保存到 +Herdr 管理的插件数据下并注册。非交互式安装用 `--yes`。重新安装 GitHub +管理的插件会替换该托管检出。已安装和已链接的插件及其启用状态对当前用户 +全局生效,可在所有 Herdr 会话中使用。即使 Herdr 服务器没有运行,也可以通过 +`plugin install` 和 `plugin link` 注册插件。仅安装在 Herdr 0.7.3 命名会话中的 +插件必须重新 install 或 link;现有的插件配置和状态会保留。 +不允许覆盖安装到本地链接的插件之上;请先 unlink 或 uninstall 本地插件。 +`plugin install` 和 `plugin link` 会创建 +插件的配置和状态目录,`plugin config-dir <id>` 打印配置目录,方便安装 +文档和 shell 脚本使用。 + +`plugin uninstall <id-or-source>` 注销插件。对 GitHub 管理的安装,它还会 +删除托管检出,并且既接受插件 id,也接受与 install 相同的 +`owner/repo[/subdir...]` 简写。`plugin unlink <id>` 只注销插件、不动文件, +对本地开发很有用。v1 没有单独的 `plugin update`;要刷新托管插件,请从 +GitHub 重新安装。 + +示例菜谱仓库是 `ogulcancelik/herdr-plugin-examples`。它在子目录中包含 +多个独立示例插件,包括 `agent-telegram-notify`、`github-link-preview` 和 +`dev-layout-bootstrap`。这些是供复制的示例,不是持续维护的官方插件。 + +## 构建命令 + +构建命令在 GitHub `plugin install` 过程中运行,时机在确认之后、Herdr +注册插件之前。如果构建命令失败,安装中止,插件不会被注册。`plugin link` +不运行构建命令;本地作者自己构建工作树。构建命令可以生成文件,但在 +安装预览之后修改 `herdr-plugin.toml` 会导致安装中止。构建失败时会显示 +插件 id、构建序号、工作目录、命令、退出状态或 spawn 错误,以及截断后的 +stdout/stderr,不会解读工具输出。 + +构建命令同样是普通的 argv 命令,但它们不会收到运行时插件上下文或 +Herdr socket 环境变量。插件作者应在文档中说明所需的系统工具,比如 +`cargo`、`npm`、`bun` 或 `lua`;Herdr 报告构建失败,但不会安装缺失的 +工具链。 + +## 启动钩子 + +Herdr 恢复会话且 API socket 就绪后,会为每个已启用插件运行一次 +`[[startup]]` 命令。实时交接由新服务器接管时会再次运行,但客户端连接、 +配置重载以及插件 link 或 enable 时不会运行。Herdr 异步启动这些命令,并在 +普通插件命令日志中记录完成情况。启动钩子失败不会停止服务器。 + +启动钩子是一次性初始化命令,不是受监督的守护进程。钩子应恢复插件自己的 +状态、调用所需的 Herdr API,然后退出。例如,插件可以把声明式 Agent 视图保存到 +`HERDR_PLUGIN_STATE_DIR`,再由启动钩子读取并重新应用。 + +启动钩子会收到普通运行时插件环境和 `HERDR_PLUGIN_EVENT=startup`。安装预览会 +列出所有启动命令,让用户检查将自动运行的代码。 + +## 命令与环境 + +运行时命令以插件目录为工作目录执行。Herdr 注入 `HERDR_SOCKET_PATH`、 +`HERDR_BIN_PATH`、`HERDR_ENV=1`、`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、 +`HERDR_PLUGIN_CONFIG_DIR`、`HERDR_PLUGIN_STATE_DIR`、 +`HERDR_PLUGIN_CONTEXT_JSON`,以及可用时的 `HERDR_WORKSPACE_ID`、 +`HERDR_TAB_ID` 和 `HERDR_PANE_ID`。动作命令还会收到 +`HERDR_PLUGIN_ACTION_ID`;启动钩子和事件钩子收到 `HERDR_PLUGIN_EVENT` +(启动钩子中的值为 `startup`),事件钩子还会收到 +`HERDR_PLUGIN_EVENT_JSON`;窗格命令收到 `HERDR_PLUGIN_ENTRYPOINT_ID`。 + +`HERDR_PLUGIN_ROOT` 是已安装或已链接的插件目录。不要把用户凭据或持久 +状态放在那里,因为 GitHub 安装的插件根目录是托管的源码检出。把 `.env` +这类用户可编辑的配置放在 `HERDR_PLUGIN_CONFIG_DIR` 下,把本地运行时 +状态放在 `HERDR_PLUGIN_STATE_DIR` 下。Herdr 会创建这些目录,并在旧版 +插件配置位置存在时把内容初始化到 `HERDR_PLUGIN_CONFIG_DIR`,但不会校验、 +同步或删除其中的内容。文件格式和生命周期归插件所有。 + +`HERDR_PLUGIN_CONTEXT_JSON` 在本次调用可用时,可以包含工作区、标签页、 +聚焦窗格、worktree、智能体、选中文本、点击的 URL 和链接处理器字段。 +shell 插件可以从各个环境变量读取常用 id,或解析上下文 JSON 获取完整结构。 + +当插件需要从 Node、PowerShell、Bash 或其他运行时可移植地调用 Herdr 时, +使用 `HERDR_BIN_PATH`。`HERDR_SOCKET_PATH` 背后的原始 socket 传输是 +操作系统相关的: Unix 客户端连接 Unix socket 路径,Windows 客户端连接 +命名管道。通过 `HERDR_BIN_PATH` 的 CLI 调用可以避开这种传输差异。可用 +命令见 [CLI 参考](/zh-cn/docs/cli-reference/),原始请求结构见 +[socket API](/zh-cn/docs/socket-api/)。 + +## 窗格 + +清单中窗格的 `placement` 默认为 `overlay`,它在活动窗格上方打开一个 +临时的缩放覆盖层,关闭时恢复之前的焦点和缩放。`plugin.pane.open` 请求 +可以用 `overlay`、`popup`、`split`、`tab` 或 `zoomed` 覆盖清单的 placement。 + +`placement = "popup"` 会打开一个会话级模态终端弹窗,而不改变平铺布局。 +可以在清单或 open 请求中指定可选的 `width` 和 `height`;省略时默认为终端大小的一半,数字表示外层终端单元格数,`"80%"` 这样的字符串表示终端区域的百分比。 +弹窗会接收包括 Escape 在内的所有终端输入,并在命令退出或发送 `popup.close` 请求时关闭。 +小于弹窗最小尺寸的值会限制为最小值。 + +如果某个插件窗格应始终是临时的,可直接在入口点上声明 placement: + +```toml +[[panes]] +id = "picker" +title = "Picker" +platforms = ["linux", "macos"] +placement = "popup" +width = "80%" +height = 20 +command = ["sh", "picker.sh"] +``` + +split、tab、zoomed 和 overlay 插件窗格打开后就是普通的 Herdr 窗格。插件可以通过 socket 或 CLI 调用 +`pane.move`、`pane.swap`、`pane.resize`、`pane.zoom` 等标准窗格 API; +窗格跨标签页或工作区移动时,Herdr 会让插件窗格的所有权跟随底层窗格。 +弹窗不是 Herdr 窗格,而是会话级单例资源:它没有窗格 id,不会改变插件焦点上下文,不会发出窗格生命周期事件,也不参与 pane、layout、持久化或智能体 API。 +其进程不会收到 `HERDR_PANE_ID`;底层平铺窗格仍可通过 `HERDR_PLUGIN_CONTEXT_JSON` 获取。 +在 Settings、复制模式或其他 Herdr 模态界面打开时尝试打开弹窗会返回 `ui_busy`;启动成功后,`plugin.pane.open` 返回 `ok`。 + +在 Windows 上,构建命令、动作命令和事件命令会在裸命令位于 `PATH` 上时 +解析常见的 `PATHEXT` shim,比如 `npm.cmd`、`bun.cmd` 和 `pnpm.cmd`。 +窗格命令使用 Herdr 常规的 Windows 窗格启动器,仍然必须是有效的 Windows +argv 命令。 + +## 按键绑定 + +把某个键绑定到已安装的插件动作: + +```toml +[[keys.command]] +key = "prefix+l" +type = "plugin_action" +command = "example.layout.apply" +description = "apply layout" +``` + +## 链接处理器 + +用 `[[link_handlers]]` 把对匹配终端 URL 的修饰键点击路由到插件动作, +而不是在浏览器中打开 URL。修饰键点击的修饰键在所有平台上都是 Control, +包括 macOS,因为被捕获的终端鼠标上报无法把 Command/Super 和普通点击 +区分开。`pattern` 是对被点击 URL 匹配的 Rust 正则表达式,`action` 必须 +指向同一插件声明的动作。链接处理器动作在 `HERDR_PLUGIN_CONTEXT_JSON` +中收到 `invocation_source = "link_click"`、`clicked_url` 和 +`link_handler_id`;shell 插件也可以读取 `HERDR_PLUGIN_CLICKED_URL` 和 +`HERDR_PLUGIN_LINK_HANDLER_ID`。每个插件内的处理器按清单顺序检查。 + +## 存储 + +v1 没有 Herdr 管理的插件存储 API。需要持久状态的插件应自己管理文件或 +数据库。 + +## 插件市场 + +社区插件可以在[插件市场](/plugins/)中发现,它是打了 `herdr-plugin` 主题标签且 +包含一个或多个必需元数据可解析的 `herdr-plugin.toml` 的公开 GitHub 仓库自动索引。 +插件仍然是普通的 GitHub 仓库: 发布后分享 +`herdr plugin install owner/repo[/subdir]`。 + +要让插件被收录,给公开仓库添加 GitHub 主题标签 `herdr-plugin`,并在默认分支的 +根目录或子目录放置清单。一张仓库卡片可以包含多个插件。索引每 30 分钟刷新一次。 +发现机制的工作方式见 +[插件市场](/zh-cn/docs/marketplace/)。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/quick-start.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/quick-start.mdx new file mode 100644 index 00000000..87211e28 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/quick-start.mdx @@ -0,0 +1,69 @@ +--- +title: 快速开始 +description: 创建你的第一个 Herdr 工作区,在持久的终端窗格中运行智能体。 +--- + +如果还没有安装 Herdr,请参见[安装](/zh-cn/docs/install/)。然后在任意项目目录中启动 Herdr: + +```bash +herdr +``` + +Herdr 会启动或连接到你的默认后台会话。你不需要管理 socket。即使分离,智能体也会继续运行。 + +## 创建工作区 + +当会话中没有工作区时,Herdr 会自动打开一个。工作区是项目级别的容器,容纳标签页、窗格和智能体。给每个活跃项目一个独立的工作区,这样侧边栏里的智能体状态才清晰可读。 + +## 使用鼠标 + +Herdr 是鼠标原生的,所以从点击开始。点击窗格、标签页、工作区和智能体来聚焦它们。拖动分割边框来调整大小。右键打开上下文菜单,包括分割窗格和创建标签页。拖选文本即可复制到剪贴板。双击一个词可以选中它;按住第二次点击并拖动,会按整词扩展选择。两种操作都会在松开鼠标时复制。复制不需要 Ctrl+C。 + +当终端把带修饰键的点击传给 Herdr 时,Ctrl+点击可以打开窗格内的链接。这适用于 OSC 8 超链接及 `http://` 或 `https://` URL,包括首尾部分位于可见区域之外的换行链接。按住 Ctrl 并将指针移到链接上,会为可见部分添加下划线。移开指针、窗格内容变化或收到 Ctrl 释放事件时,高亮会清除;部分终端只在鼠标移动时报告修饰键变化。在 macOS 上,鼠标捕获开启时,请用 Ctrl+点击来触发 Herdr 处理的窗格链接;Cmd+点击只能通过终端原生的绕过路径使用,比如 Shift+Cmd+点击或 `ui.mouse_capture = false`。 + +如果配置了 `ui.right_click_passthrough_modifier`,该修饰键加右键会把右键点击、按住和拖动手势发送给启用鼠标上报的窗格应用。 + +## 运行智能体 + +在窗格里启动你的编程智能体: + +```bash +claude +``` + +也可以是 `codex`、`pi`、`opencode` 或任何其他[受支持的智能体](/zh-cn/docs/agents/)。Herdr 会自动检测。侧边栏会显示每个智能体处于 `working`、`blocked`、`done` 还是 `idle` — 并且跨所有工作区显示,所以你总能知道哪个项目需要你。 + +## 键盘操作 + +键盘操作是可选的;鼠标可以完成一切。按 `ctrl+b` 进入前缀模式,然后按一个动作键。 + +常用动作: + +| 动作 | 按键 | +| --- | --- | +| 向右分割 | `prefix+v` | +| 向下分割 | `prefix+minus` | +| 新建标签页 | `prefix+c` | +| 下一个 / 上一个标签页 | `prefix+n` / `prefix+p` | +| 工作区导航 | `prefix+w` | +| 新建工作区 | `prefix+shift+n` | +| 分离客户端 | `prefix+q` | + +第一次接触前缀键的概念?[键盘](/zh-cn/docs/keyboard/)会解释它是什么、为什么终端复用器要用它,以及如何做到不用前缀键。在 Herdr 里按 `prefix+?` 查看所有生效的绑定,按 `prefix+[` 进入复制模式用键盘复制。 + +## 分离与回来 + +按 `prefix+q`,或者直接关掉终端窗口。Herdr 服务器和所有智能体会继续运行。再次运行 `herdr` 即可重新连接到同一个会话。 + +要真正结束会话并停止其中的窗格: + +```bash +herdr server stop +``` + +## 接下来 + +- [核心概念](/zh-cn/docs/concepts/) — 两分钟了解工作区、标签页、窗格和智能体模型。 +- [使用 Herdr 的工作方式](/zh-cn/docs/how-to-work/) — 本地、SSH、手机和 `herdr --remote` 工作流。 +- [智能体](/zh-cn/docs/agents/) — 受支持的智能体、检测,以及提升状态准确度的集成。 +- [配置](/zh-cn/docs/configuration/) — 按键绑定、主题、通知,以及其他一切。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/session-state.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/session-state.mdx new file mode 100644 index 00000000..c4c4a387 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/session-state.mdx @@ -0,0 +1,116 @@ +--- +title: 会话状态与恢复 +description: 理解 Herdr 保持哪些实时状态、重启后恢复什么、从历史回放什么、通过智能体集成恢复什么会话,以及更新时如何交接。 +--- + +Herdr 有多条状态路径,它们解决的是不同的问题。 + +## 什么能保留下来 + +| 场景 | 进程继续运行 | 布局恢复 | 最近的屏幕内容恢复 | 智能体对话恢复 | +| --- | --- | --- | --- | --- | +| 分离并重新连接 | 是 | 是 | 是,来自实时终端 | 是,因为进程从未停止 | +| 服务器重启 | 否 | 是 | 仅在开启窗格屏幕历史时 | 仅在有智能体原生会话恢复时 | +| 不带 `--handoff` 的更新 | 兼容的服务器继续运行;需要重启的服务器可能要停止/重启 | 重启后恢复 | 仅在开启窗格屏幕历史时 | 仅在有智能体原生会话恢复时 | +| 带 `--handoff` 的更新 | 对受支持的运行中服务器尽力而为 | 是 | 是,交接成功时来自实时终端 | 是,交接成功时进程保持运行 | + +下面的小节逐一解释这些路径。 + +## 实时持久化 + +普通分离会让 Herdr 服务器继续运行。窗格、shell、智能体、服务器、测试和命令进程都在该服务器中继续运行。 + +用 `ctrl+b q` 分离客户端。之后重新连接: + +```bash +herdr +``` + +这是最强的持久化路径,因为原始进程从未停止。 + +## 快照恢复 + +如果 Herdr 服务器停止后再启动,原来的窗格进程已经不在了。Herdr 会恢复保存的会话形态: 工作区、标签页、窗格、cwd、布局和焦点。 + +快照恢复不会保留运行中的 shell、服务器、测试或任意进程。无法使用更强恢复路径的窗格,会在各自保存的目录中作为新 shell 回来。 + +如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。正常加载的会话不会创建恢复副本。 + +Herdr 保留最新的三个恢复副本,只有在新副本安全写入后才删除旧副本。如果无法保存副本,自动保存和退出保存都不会修改原始文件,并会记录错误,在下一次保存请求时重试。副本不会自动恢复。如需恢复,请停止对应的服务器,将恢复文件复制到该服务器的 `session.json`,然后重启。恢复副本不包含窗格屏幕历史。 + +## 窗格屏幕历史回放 + +窗格屏幕历史在服务器完全重启后恢复最近的终端内容。它恢复的是 Herdr 能展示的内容,而不是原来的进程。 + +它默认关闭,因为窗格输出可能包含密钥、令牌、提示词和命令输出。可以在配置文件中开启: + +```toml +[experimental] +pane_history = true +``` + +开启后,Herdr 把保存的窗格历史存放在 `session.json` 旁边的 `session-history.json` 中。请像对待终端历史一样对待 Herdr 的配置/会话目录。 + +## 智能体原生会话恢复 + +一些智能体可以恢复它们自己的对话会话。Herdr 可以使用官方集成上报的会话引用,在 Herdr 服务器重启后重新启动受支持的智能体窗格。 + +这默认开启。关闭方法: + +```toml +[session] +resume_agents_on_restore = false +``` + +Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引用的窗格。 + +当客户端连接并提供终端尺寸和主题上下文后,Herdr 会跨工作区和标签页恢复符合条件的智能体窗格,不需要等每个窗格被聚焦。 + +智能体原生会话恢复需要以下版本或更新的 Herdr 集成: + +| 智能体 | 最低 Herdr 集成版本 | 恢复命令 | +| --- | --- | --- | +| Pi | `2` | `pi --session <path-or-id>` | +| OMP | `3` | `omp --resume=<path-or-id>` | +| Claude Code | `6` | `claude --resume <id>` | +| Codex | `5` | `codex resume <id>` | +| Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | +| Grok CLI | `2` | `grok --resume <id>` | +| GitHub Copilot CLI | `2` | `copilot --resume=<id>` | +| Devin CLI | `2` | `devin --resume <id>` | +| Droid | `2` | `droid --resume <id>` | +| Kimi Code CLI | `3` | `kimi --session <id>` | +| Qoder CLI | `2` | `qodercli --resume <id>` | +| Qwen Code | `1` | `qwen --resume <id>` | +| Letta Code | `1` | `letta --conversation <id>`,或对 `default:<agent-id>` 使用 `letta --conversation default --agent <agent-id>` | +| OpenCode | `5` | `opencode --session <id>` | +| Kilo Code CLI | `1` | `kilo --session <id>` | +| Hermes Agent | `2` | `hermes --resume <id>` | +| MastraCode | `1` | `mastracode --thread <id>` | + +运行 `herdr integration status` 查看已安装的集成版本。用 `herdr integration install <agent>` 重新安装过期的集成。 + +不受支持、缺失、无效、重复或过期的会话引用,会在保存的窗格目录中作为普通 shell 恢复。 + +如果某个窗格适用智能体原生会话恢复,Herdr 会对该窗格恢复智能体会话,而不是回放保存的窗格历史。 + +## 实时交接 + +实时交接用于需要替换运行中 Herdr 服务器的更新和远程连接流程。它请求旧服务器把实时窗格转移给新服务器,让窗格进程跨服务器替换继续运行。 + +这与快照恢复、窗格历史回放和智能体原生会话恢复不同。交接尝试让当前进程活下去,其他路径则是在旧服务器已经停止之后重建状态。 + +交接保护的是由服务器拥有的长期会话状态:窗格 PTY 和进程、智能体身份与持久元数据,以及替换服务器所需的插件/会话状态。它不会跨替换边界保留临时协调状态。进行中的 CLI 或 API 请求、等待、订阅流、客户端套接字和窗格间消息可能会中断;客户端应重新连接并重试。 + +更新后的 Unix 服务器可以分批传输,在一次交接中发送超过 64 个窗格。发送方服务器必须已经支持此功能;从旧服务器进行第一次升级时,仍受其原有的 64 窗格限制。此时可能需要正常重启服务器,这会结束窗格进程。分批传输不会改变更新的安装顺序,也不会取消其他交接兼容性要求。 + +实时交接是实验性功能,需要主动开启: + +```bash +herdr update --handoff +herdr --remote workbox --handoff +``` + +普通的 `herdr update` 会安装新客户端,并让端点第 1 代服务器继续运行。普通的 `herdr --remote workbox` 也会在版本不同时保留兼容的远程服务器。只有从早于第 1 代的服务器做一次性升级时才需要停止;如果要在不结束窗格进程的情况下明确替换受支持的运行中服务器,请使用 `--handoff`。 + +`herdr update --handoff` 只适用于由 Herdr 自带更新器管理的安装。Homebrew、mise 和 Nix 安装通过各自的包管理器更新,因此那些安装中 `herdr update` 被禁用,无法执行实时交接。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/socket-api.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/socket-api.mdx new file mode 100644 index 00000000..43400eaa --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/socket-api.mdx @@ -0,0 +1,750 @@ +--- +title: Socket API +description: 从脚本、工具和编程智能体控制运行中的 Herdr 服务器。 +--- + +Herdr 为需要检查或控制运行中会话的脚本和智能体提供了一个本地 socket API。 + +大多数自动化应从 CLI 包装命令开始。只有在需要直接的请求/响应控制或长期事件订阅时,才使用原始 socket API。 + +## 选择集成层 + +| 层 | 用途 | +| --- | --- | +| 智能体技能 | 教编程智能体如何在窗格内使用 Herdr。 | +| CLI 包装 | Shell 脚本、简单编排和人工调试。 | +| 原始 socket API | 自定义工具、协议客户端和事件订阅者。 | + +这些层共享同一套控制面。 + +## Schema + +安装的 CLI 可以打印该 Herdr 二进制中包含的 socket 协议 schema: + +```bash +herdr api schema +herdr api schema --json +herdr api schema --output herdr-api.schema.json +``` + +普通的 `herdr api schema` 打印简短摘要。`--json` 打印供工具使用的完整 JSON Schema,`--output PATH` 将该文档写入文件。schema 覆盖原始请求、成功响应、错误响应、发出的事件和订阅事件。 + +## 你能控制什么 + +socket API 可以: + +- 创建、列出、聚焦、重命名和关闭工作区 +- 创建、列出、聚焦、重命名和关闭标签页 +- 列出、检查、分割、交换、聚焦、调整、重命名、读取、关闭窗格并向其发送输入 +- 通过 CLI 辅助命令列出、检查、读取、提示、等待、重命名、聚焦、启动和附加智能体 +- 从钩子和插件上报自定义智能体状态 +- 订阅事件并等待输出或状态变化 +- 安装和卸载内置集成 +- 停止服务器并重载配置 + +## CLI 示例 + +创建工作区: + +```bash +herdr workspace create --cwd ~/project --label api +``` + +创建标签页: + +```bash +herdr tab create --label logs +``` + +分割窗格并运行命令: + +```bash +herdr pane split w1:p1 --direction right +herdr pane run w1:p2 "npm test" +``` + +检查并重排窗格: + +```bash +herdr pane layout --current +herdr pane neighbor --direction right --current +herdr pane resize --direction right --amount 0.1 --current +herdr pane swap --direction right --current +herdr pane zoom --on --current +herdr pane split w1:p1 --direction right --ratio 0.333 +``` + +等待智能体: + +```bash +herdr agent wait w1:p1 --until done +``` + +读取窗格输出: + +```bash +herdr pane read w1:p2 --source recent --lines 50 +``` + +## 原始方法 + +原始 socket 方法名使用点号记法: + +| 领域 | 方法 | +| --- | --- | +| 服务器 | `ping`、`server.stop`、`server.reload_config`、`server.agent_manifests`、`server.reload_agent_manifests` | +| 通知 | `notification.show` | +| 客户端 | `client.window_title.set`、`client.window_title.clear` | +| 会话 | `session.snapshot` | +| 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | +| Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | +| 标签页 | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | +| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | +| 弹窗 | `popup.close` | +| 布局 | `layout.export`、`layout.apply`、`layout.set_split_ratio` | +| 智能体 | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` | +| 事件 | `events.subscribe`、`events.wait` | +| 集成 | `integration.install`、`integration.uninstall` | +| 插件 | `plugin.link`、`plugin.list`、`plugin.unlink`、`plugin.enable`、`plugin.disable`、`plugin.action.list`、`plugin.action.invoke`、`plugin.log.list`、`plugin.pane.open`、`plugin.pane.focus`、`plugin.pane.close` | + +`agent.wait` 由服务器拥有并由事件驱动。它会固定到已解析的窗格占用者,因此替换后的智能体不能满足该等待。`agent.prompt` 接受可选的 `wait` 对象,其中包含 `until` 和 `timeout_ms`;这样可在一个请求中提交提示并开始等待,避免两个独立调用之间的竞态。如果解析出的智能体已经是 `blocked`,`agent.prompt` 不会发送输入或开始等待,而是返回 `agent_blocked`。 + +`workspace.move_block` 会将有序的 `workspace_ids` 原子地移动到 `before_workspace_id` 之前;省略锚点则将该块移动到末尾。id 必须唯一,且锚点不能属于被移动的块。响应包含服务器确认的有序工作区列表。 + +`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。为避免引导期间漏掉事件,应先在另一个连接上打开 `events.subscribe` 并等待确认,在调用 `session.snapshot` 时缓冲该事件流,然后安装快照、按顺序应用缓冲的事件并继续接收事件。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。 + +CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSON,让客户端和智能体可以简单地完成引导。 + +窗格控制方法使用 `w1:p1` 这类公开窗格 id。schema 中 `pane_id` 可选的方法,在省略它时使用服务器当前聚焦的活动窗格。`pane.move` 总是要求来源 `pane_id`。 + +`pane.send_keys` 和 `pane.send_input.keys` 接受 Herdr 组合键字符串: 普通可打印键、`enter` 和 `esc` 这类特殊键、`ctrl+h`、`control+j`、`alt+x`、`shift+tab` 这类修饰组合键、`f1` 这类功能键,以及 `minus` 和 `plus` 这类命名标点。它们不接受 `prefix+` 绑定字符串。 + +```json +{"id":"req_current","method":"pane.current","params":{"caller_pane_id":"w1:p1"}} +{"id":"req_layout","method":"pane.layout","params":{"pane_id":"w1:p1"}} +{"id":"req_neighbor","method":"pane.neighbor","params":{"pane_id":"w1:p1","direction":"right"}} +{"id":"req_edges","method":"pane.edges","params":{"pane_id":"w1:p1"}} +{"id":"req_focus","method":"pane.focus_direction","params":{"direction":"right"}} +{"id":"req_resize","method":"pane.resize","params":{"pane_id":"w1:p1","direction":"right","amount":0.1}} +{"id":"req_zoom","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"toggle"}} +{"id":"req_split","method":"pane.split","params":{"direction":"right","ratio":0.333,"env":{"HERDR_ROLE":"tests"}}} +{"id":"req_process","method":"pane.process_info","params":{"pane_id":"w1:p1"}} +``` + +`pane.current` 返回单个 `PaneInfo`。带有 `caller_pane_id` 时,Herdr 返回那个窗格。省略时,Herdr 返回当前聚焦的活动窗格。 + +终端滚动指标可用时,`PaneInfo` 包含 `scroll`: + +```json +{ + "offset_from_bottom": 12, + "max_offset_from_bottom": 240, + "viewport_rows": 30 +} +``` + +客户端可以把 `offset_from_bottom == 0` 视为位于底部。 + +### 窗格图形 + +窗格图形允许插件在窗格上放置图像数据。该功能默认可用;当服务器设置 `[terminal].kitty_graphics = false` 时,所有窗格图形方法都返回 `feature_disabled`。更改此设置需要重启服务器。调用 `pane.graphics.info` 会返回所连接客户端的单元格像素宽度和高度,但不会创建图形层。`pane.graphics.set` 接受 `data_base64` 中的 `png`、`rgb` 或 `rgba` 数据,`pane.graphics.clear` 删除该图层。 + +```json +{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} +{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} +{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} +``` + +要重复发送帧,用 `pane.graphics.stream` 打开专用 socket。Herdr 回复 `ok` 后,每一帧发送一个 JSON 头,再发送恰好 `data_length` 字节的原始数据。socket 关闭前,该流拥有对应窗格的图形层;并发 set、clear 或 stream 请求返回 `stream_conflict`。 + +```json +{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}} +{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} +``` + +`pane.layout` 返回标签页布局快照,包含 `workspace_id`、`tab_id`、`zoomed`、外层 `area`、`focused_pane_id`、窗格矩形和分割矩形/比例。`pane.neighbor` 和 `pane.edges` 也包含同一份布局快照,让客户端不需要私有布局状态就能做出下一步决策。 + +`pane.process_info` 返回窗格的 shell pid、可用时的前台进程组 id,以及平台暴露时带有 pid、名称、argv/cmdline 和 cwd 的前台进程。 + +`layout.export` 返回可移植的标签页布局树。省略 `tab_id` 和 `pane_id` 导出活动标签页,传 `tab_id` 导出该标签页,或传 `pane_id` 导出包含该窗格的标签页。 + +```json +{"id":"req_export","method":"layout.export","params":{"tab_id":"w1:t1"}} +``` + +响应包含 `workspace_id`、`tab_id`、`zoomed`、`focused_pane_id` 和 `root`。`root` 是由 `pane` 和 `split` 节点组成的 BSP 树。窗格节点可以包含 `pane_id`、`label`、`cwd` 和 argv `command`。分割节点使用 `direction` (`right` 或 `down`)、`ratio`、`first` 和 `second`。 + +`layout.apply` 从声明式的树创建一个新标签页。提供 `tab_id` 时,Herdr 先创建替代标签页,再关闭旧标签页。它会恢复结构、标签、cwd、env 和可选的 argv 命令;不会保留活跃的 PTY、回滚内容或运行中的进程。 + +```json +{ + "id": "req_apply", + "method": "layout.apply", + "params": { + "workspace_id": "wabc", + "tab_label": "dev", + "focus": true, + "root": { + "type": "split", + "direction": "right", + "ratio": 0.65, + "first": { + "type": "pane", + "label": "editor", + "cwd": "/repo" + }, + "second": { + "type": "pane", + "label": "tests", + "cwd": "/repo", + "command": ["sh", "-c", "just test"], + "env": { "HERDR_ROLE": "tests" } + } + } + } +} +``` + +`layout.set_split_ratio` 更新标签页布局中已有分割的比例。响应是 `type: "layout_split_ratio_set"`,并包含更新后的可移植 `layout`。 + +```json +{"id":"req_ratio","method":"layout.set_split_ratio","params":{"tab_id":"w1:t1","path":[],"ratio":0.6}} +``` + +启动进程的方法接受一个 `env` 对象。Herdr 只把这些键值对应用到新启动的进程。Herdr 还向受管窗格进程注入 `HERDR_SOCKET_PATH`、`HERDR_ENV=1`、`HERDR_WORKSPACE_ID`、`HERDR_TAB_ID` 和 `HERDR_PANE_ID`。与调用方提供的环境变量冲突时,Herdr 管理的变量保持权威。 + +`pane.swap` 支持按方向和显式两种形式: + +```json +{"id":"req_swap_dir","method":"pane.swap","params":{"pane_id":"w1:p1","direction":"right"}} +{"id":"req_swap_explicit","method":"pane.swap","params":{"source_pane_id":"w1:p1","target_pane_id":"w1:p2"}} +``` + +交换仅限同一标签页。它保留分割形状、分割比例、窗格 id 和运行中的进程。响应是 `type: "pane_swap"`,带 `changed`、可选的 `reason`、`source_pane_id`、可选的 `target_pane_id`、`focused_pane_id` 和 `layout`。reason 的取值有 `no_neighbor`、`same_pane`、`not_found` 和 `cross_tab`。标签页处于缩放状态时,交换保持缩放,并修改隐藏的整页布局。 + +`pane.move` 把运行中的窗格移动到另一个标签页、新标签页或新工作区: + +```json +{"id":"req_move_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"tab","tab_id":"w1:t2","target_pane_id":"w1:p3","split":"right","ratio":0.5},"focus":true}} +{"id":"req_move_new_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_tab","workspace_id":"w1","label":"logs"},"focus":true}} +{"id":"req_move_new_workspace","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_workspace","label":"logs","tab_label":"main"},"focus":true}} +``` + +移动到已有标签页需要 `split: "right" | "down"`。`target_pane_id` 可选,默认是目标标签页的聚焦窗格。同一标签页内的布局变化仍然用 `pane.swap`;移动到来源标签页返回 `changed: false` 和 `reason: "same_tab"`。涉及缩放状态的来源或目标标签页的移动返回 `changed: false` 和 `reason: "zoomed_tab"`。 + +响应是 `type: "pane_move"`,带 `changed`、可选的 `reason`、`previous_pane_id`、`previous_workspace_id`、`previous_tab_id`、被移动的 `pane`、可选的 `source_layout`、`target_layout`、可选的新建工作区或标签页记录、可选的已关闭工作区或标签页 id,以及 `focused_pane_id`。跨工作区移动保持内部窗格和终端存活,但在目标工作区分配新的公开窗格 id。订阅者可以监听 `pane.moved`;Herdr 不会为被移动的终端进程发出假的窗格关闭/创建事件。 + +`pane.zoom` 切换、启用或禁用目标窗格所在标签页的缩放: + +```json +{"id":"req_zoom_toggle","method":"pane.zoom","params":{"pane_id":"w1:p1"}} +{"id":"req_zoom_on","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"on"}} +{"id":"req_zoom_off","method":"pane.zoom","params":{"pane_id":"w1:p1","mode":"off"}} +``` + +省略 `pane_id` 时,目标是服务器当前聚焦的活动窗格。响应是 `type: "pane_zoom"`,带 `changed`、`zoom_changed`、`focus_changed`、可选的 `reason`、`pane_id`、`focused_pane_id`、`zoomed` 和 `layout`。缩放状态或焦点任一发生变化时,`changed` 为 true。reason 的取值有 `single_pane`、`already_zoomed` 和 `already_unzoomed`。 + +`notification.show` 的 CLI 包装是: + +```bash +herdr notification show "build failed" --body "api workspace" --position top-left --sound request +``` + +通过配置的 toast 投递方式显示用户通知: + +```json +{"id":"req_notify","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}} +``` + +`title` 必填,并且在移除控制字符和重复空白后必须仍有可见文本。`body` 可选。Herdr 把换行、制表符、回车和重复空白折叠为空格,然后把通知文本截断: `title` 80 个字符,`body` 240 个字符。净化后为空的 `title` 返回 `invalid_params`。`position` 可选,只在 `ui.toast.delivery = "herdr"` 时生效;桌面位置相对于完整的 Herdr 画面,省略时使用 `ui.toast.herdr.position`。terminal、system 和 off 投递忽略 `position`。`sound` 可选,取值 `none`、`done` 或 `request`;默认 `none`,且只在通知实际显示时播放。 + +响应会报告是否有内容被显示: + +```json +{"id":"req_notify","result":{"type":"notification_show","shown":true,"reason":"shown"}} +``` + +可能的 reason 有 `shown`、`disabled`、`rate_limited`、`no_foreground_client` 和 `busy`。`disabled` 表示 `ui.toast.delivery = "off"`。`busy` 表示已有的应用内 toast 未被替换。terminal 和 system 投递是通过当前前台连接的 Herdr 客户端尽力而为的。 + +设置或清除前台客户端的外层终端窗口标题: + +```json +{"id":"req_title","method":"client.window_title.set","params":{"title":"herdr api"}} +{"id":"req_title_clear","method":"client.window_title.clear","params":{}} +``` + +`client.window_title.clear` 把标题交还给 `ui.window_title`。响应是 `type: "client_window_title"`,带 `changed` 和 `set`、`cleared` 或 `no_foreground_client` 之一的 reason。 + +Worktree 方法把 Git 检出作为 Herdr 工作区管理。`worktree.create` 创建检出,并返回新的 `workspace`、`tab`、`root_pane` 和 `worktree` 记录。请求的分支已在本地存在时检出它;否则从请求的 base 或 `HEAD` 创建分支。`worktree.open` 打开已有检出,或返回已打开的工作区。`worktree.remove` 对关联的子工作区运行 `git worktree remove`,从不删除分支。 + +从来源工作区创建 worktree: + +```json +{"id":"req_1","method":"worktree.create","params":{"workspace_id":"w1","branch":"worktree/api","focus":false}} +``` + +打开已有检出: + +```json +{"id":"req_2","method":"worktree.open","params":{"workspace_id":"w1","branch":"worktree/api","focus":true}} +``` + +移除关联的检出: + +```json +{"id":"req_3","method":"worktree.remove","params":{"workspace_id":"2","force":false}} +``` + +`worktree.list`、`worktree.create` 和 `worktree.open` 中,`workspace_id` 和 `cwd` 最多用一个;两个都省略则使用活动工作区。`worktree.open` 中,`path` 和 `branch` 恰好用一个。原始 socket 的 `cwd` 和 `path` 值必须是绝对路径;CLI 在发送请求前会展开相对的 `--cwd` 和 `--path` 值。当工作区属于某个 Herdr worktree 组时,工作区响应包含可选的 `worktree` 来源信息。当已有工作区获得或改变 worktree 来源信息时,worktree 命令可能发出 `workspace.updated`。 + +Worktree 命令也发出生命周期事件。`worktree.create` 发出 `workspace.created`、`tab.created`、`pane.created` 和 `worktree.created`。`worktree.open` 发出 `worktree.opened`,并在打开新的 Herdr 工作区时同时发出工作区/标签页/窗格创建事件。`worktree.remove` 发出 `worktree.removed`;如果关联的工作区仍然打开,还会发出 `workspace.closed`。 + +当关联的 worktree 工作区仍然打开时,父工作区只有在 `workspace.close` 的 params 中包含 `"close_group": true` 时才会被关闭;缺少显式关闭组意图时会返回 `workspace_group_close_required`。显式关闭组时,每个被关闭的工作区都会发出一个 `workspace.closed` 事件。 + +## 智能体视图查询 + +`agent.view.set` 为内置 Agents 视图设置一个临时的声明式投影。智能体信息或当前 +UI 上下文变化时,该投影会重新求值。它控制展开和折叠的侧边栏、移动端 Agents +列表、鼠标目标、编号聚焦以及上一个/下一个智能体的导航顺序。它不会改变 +`agent.list`、通知、检测或全局待处理计数。 + +下面的示例显示当前呈现的 Space 中的智能体,以及其他位置需要处理的智能体, +然后按待处理优先级和最近状态转换排序: + +```json +{ + "id": "view_set", + "method": "agent.view.set", + "params": { + "source": "plugin:example.agent-views", + "label": "focus", + "filter": { + "op": "any", + "filters": [ + { + "op": "eq", + "field": "workspace_id", + "value": {"context": "current_workspace_id"} + }, + { + "op": "in", + "field": "status", + "values": ["blocked", "done"] + } + ] + }, + "sort": [ + {"field": "attention", "order": "desc"}, + {"field": "state_change_seq", "order": "desc"} + ] + } +} +``` + +过滤节点的 `op` 可以是 `all`、`any`、`not`、`eq`、`in` 或 `exists`。内置 +过滤字段是 `status`、`workspace_id`、`tab_id`、`pane_id`、`agent`、`seen` +和 `state_change_seq`。要过滤插件上报的窗格元数据,使用 `{"token":"name"}` +作为字段。值可以是字符串、布尔值、无符号整数或上下文对象。上下文值包括 +`current_workspace_id` 和 `current_tab_id`,并且只能与对应的 ID 字段比较。在保存机器客户端中,所选服务器的视图应用于合并后的智能体列表。当前工作区和标签页上下文也包含所选机器,因此其他机器上的相同 ID 不会匹配;而 `status` 为 `blocked` 等独立条件仍可匹配任意已连接机器上的智能体。 +有效 status 值为 `idle`、`working`、`blocked`、`done` 和 `unknown`;`done` +表示 idle 且尚未查看。 + +排序字段包括 `workspace_order`、`tab_order`、`pane_order`、`attention`、 +`status`、`agent`、`seen`、`state_change_seq` 或 `{"token":"name"}`。排序是 +稳定的,按声明顺序求值,并接受 `asc` 或 `desc`。缺失值排在存在值之后。省略 +`sort` 时,现有 `ui.agent_panel_sort` 策略继续生效。自定义排序会临时替换该 +策略,但不会重写配置。 + +`source` 标识所有者。插件使用 `plugin:<HERDR_PLUGIN_ID>`;当所属插件不存在 +或被禁用时,Herdr 会拒绝设置。其他调用方可以使用自己的非 `plugin:` source。 +设置成功会原子替换之前的视图。该视图会持续到被 clear、被替换、所属插件被 +disable、unlink 或 uninstall,或者服务器退出。需要持久行为的插件应把查询保存到 +`HERDR_PLUGIN_STATE_DIR`,并从 `[[startup]]` 钩子重新应用。 + +可以无条件 clear,也可以只在指定 source 仍拥有视图时 clear: + +```json +{"id":"view_clear","method":"agent.view.clear","params":{}} +{"id":"view_clear_owned","method":"agent.view.clear","params":{"source":"plugin:example.agent-views"}} +``` + +source 不匹配时,活动视图保持不变。set 和 clear 响应使用 +`type: "agent_view"`,并报告 `active`、`source` 和可选的 `label`。 + +## 插件 API + +插件 API 是面向可执行工作流工具的早期宿主面。插件是带 `herdr-plugin.toml` 清单的包。清单声明可分享的动作、事件钩子、终端窗格入口点和链接处理器。动作和窗格仅限清单声明;运行时动作注册和运行时 argv 窗格创建不在 v1 范围内。 + +安装和链接的插件跨重启持久化。在 `plugin.link`、`plugin.unlink`、`plugin.enable` 和 `plugin.disable` 时,Herdr 在 `session.json` 旁写入一个 `plugins.json` 注册表文件。Herdr 不在运行时,`herdr plugin install` CLI 也写同一个注册表,然后启动时自动加载。启动时,Herdr 从原始路径重新读取每个清单;文件缺失或无法解析时,条目会带着 `warnings` 字段保留,`plugin.list` 会将其展示出来。 + +事件钩子的 `on` 值在链接时会对照已知的 Herdr 事件名校验。无法识别的名称不算错误 — 链接仍会成功 — 但返回的插件信息会包含警告 (例如 `"unknown event 'worktree.craeted'"`)。检查 `plugin.link` 和 `plugin.list` 响应中的 `warnings` 字段。 + +链接本地插件清单: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/path/to/plugin","enabled":true}} +``` + +`plugin.link` 也接受可选的 `source` 元数据。CLI 从 GitHub 安装时使用它,让 `plugin.list` 能显示来源、请求的 ref、解析的 commit 和托管检出路径: + +```json +{"id":"req_plugin_link","method":"plugin.link","params":{"path":"/managed/plugin/herdr-plugin.toml","enabled":true,"source":{"kind":"github","owner":"ogulcancelik","repo":"herdr-plugin-examples","subdir":"worktree-bootstrap","requested_ref":"main","resolved_commit":"abc123","managed_path":"/data/plugins/github/<managed-checkout>","installed_unix_ms":1780000000000}}} +``` + +路径可以是包含 `herdr-plugin.toml` 的插件目录,或直接指向清单的路径。清单结构如下: + +```toml +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Prepare new worktrees" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["bun", "install"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap worktree" +contexts = ["workspace"] +command = ["bun", "run", "bootstrap.ts"] + +[[events]] +on = "worktree.created" +command = ["bun", "run", "bootstrap.ts"] + +[[panes]] +id = "board" +title = "Worktree board" +placement = "overlay" +command = ["bun", "run", "board.ts"] + +[[link_handlers]] +id = "github-issue" +title = "Open GitHub issue" +pattern = "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$" +action = "bootstrap" +``` + +`min_herdr_version` 是必填项。字段缺失、无效,或比运行中的 Herdr 二进制更新时,服务器拒绝链接插件。 + +在顶层用你的插件支持的操作系统标识 (`linux`、`macos`、`windows`) 声明 `platforms`。本地开发允许省略 `platforms` — `plugin.link` 会成功,但响应包含警告。单个构建命令、动作、事件钩子、窗格和链接处理器可以声明自己的 `platforms` 来覆盖插件级列表;省略时从插件继承。调用有效 platforms 不包含当前操作系统的动作或打开这样的窗格,会返回 `platform_unsupported` 错误。 + +列出、启用、禁用或取消链接插件: + +```json +{"id":"req_plugin_list","method":"plugin.list","params":{}} +{"id":"req_plugin_disable","method":"plugin.disable","params":{"plugin_id":"example.worktree-bootstrap"}} +{"id":"req_plugin_enable","method":"plugin.enable","params":{"plugin_id":"example.worktree-bootstrap"}} +{"id":"req_plugin_unlink","method":"plugin.unlink","params":{"plugin_id":"example.worktree-bootstrap"}} +``` + +动作从链接的清单解析。`plugin.action.list` 返回所有已安装插件的全部动作;传 `plugin_id` 过滤。 + +```json +{"id":"req_plugin_actions","method":"plugin.action.list","params":{}} +{"id":"req_plugin_actions_filtered","method":"plugin.action.list","params":{"plugin_id":"example.worktree-bootstrap"}} +``` + +`plugin.action.list` 返回应用插件级继承后每个动作的有效 `platforms`。 + +用限定 id 或裸动作 id 调用动作: + +```json +{"id":"req_plugin_invoke","method":"plugin.action.invoke","params":{"action_id":"example.worktree-bootstrap.bootstrap","context":{"invocation_source":"keybinding"}}} +``` + +`plugin.action.invoke` 解析清单动作,启动清单命令,并返回 Herdr 构建的调用上下文和已启动命令的日志记录。缺失的上下文字段会从活动工作区、标签页、聚焦窗格、worktree 来源信息和请求 id 补全。调用被禁用插件的动作返回 `plugin_disabled` 错误。 + +Herdr 注入 `HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ENV=1`、`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、`HERDR_PLUGIN_CONFIG_DIR`、`HERDR_PLUGIN_STATE_DIR`、`HERDR_PLUGIN_CONTEXT_JSON`,以及可用的 `HERDR_WORKSPACE_ID`、`HERDR_TAB_ID` 和 `HERDR_PANE_ID` 值。动作命令还收到 `HERDR_PLUGIN_ACTION_ID`;事件钩子收到 `HERDR_PLUGIN_EVENT` 和 `HERDR_PLUGIN_EVENT_JSON`;窗格命令收到 `HERDR_PLUGIN_ENTRYPOINT_ID`。 + +列出最近的动作和事件命令日志: + +```json +{"id":"req_plugin_logs","method":"plugin.log.list","params":{"plugin_id":"example.worktree-bootstrap","limit":20}} +``` + +当 Herdr 发出匹配的事件名 (比如 `worktree.created`) 时,事件钩子为已启用的已安装插件运行。 + +v1 没有 Herdr 管理的插件存储 API。`HERDR_PLUGIN_CONFIG_DIR` 和 `HERDR_PLUGIN_STATE_DIR` 只提供路径发现;文件、schema、迁移和清理归插件所有。 + +打开托管终端 UI: + +```json +{"id":"req_plugin_pane","method":"plugin.pane.open","params":{"plugin_id":"example.board","entrypoint":"board","placement":"zoomed","target_pane_id":"w1:p1","env":{"HERDR_ROLE":"board"},"focus":true}} +``` + +`plugin.pane.open` 要求已安装、已启用、平台兼容的插件,然后把请求的清单 `[[panes]]` 入口点作为 argv 支撑的终端窗格启动。清单窗格的 `placement` 默认为 `overlay`;请求的 `placement` 可以用 `overlay`、`popup`、`split`、`tab` 或 `zoomed` 覆盖清单。overlay 和 popup 使用活动平铺窗格作为启动上下文。弹窗终端是会话级模态界面,不会改变标签页布局;可选的 `width` 和 `height` 以终端单元格数或 `"80%"` 这样的百分比设置外层尺寸。省略时默认为终端大小的一半,过小的值会限制为弹窗最小尺寸。弹窗没有窗格 id,不属于任何 `pane.*` 或智能体 API,不会发出窗格生命周期事件,会把插件焦点上下文保留在底层平铺窗格上,也不会向其进程导出 `HERDR_PANE_ID`。弹窗启动返回 `ok`;`popup.close` 关闭活动弹窗,没有弹窗时返回 `popup_not_open`。split 和 zoomed 针对已有窗格,tab 可以针对工作区。split、tab、zoomed 和 overlay 打开后表现得像普通 Herdr 窗格,`plugin.pane.focus` 和 `plugin.pane.close` 仍可操作这些窗格。 + +## Socket 传输 + +Herdr 在本地 socket 上使用换行分隔的 JSON。在 Unix 上,那个 socket 是 Unix 域 socket。在 Windows 上,是命名管道。 + +每行发送一个请求: + +```json +{"id":"req_1","method":"ping","params":{}} +``` + +成功响应包含相同的 `id`: + +```json +{"id":"req_1","result":{"type":"pong"}} +``` + +事件订阅在初始响应之后保持连接打开。 + +## Socket 路径 + +默认 socket 位于你的 Herdr 配置目录下。 + +命名会话有各自独立的 socket: + +```text +~/.config/herdr/herdr.sock +~/.config/herdr/sessions/<name>/herdr.sock +``` + +解析顺序: + +1. 显式的 CLI `--session <name>` +2. `HERDR_SOCKET_PATH` +3. `HERDR_SESSION=<name>` +4. 默认会话 socket + +`HERDR_SOCKET_PATH` 只用于底层覆盖。 + +对插件来说,需要可移植的 Windows 行为时,优先调用 `HERDR_BIN_PATH` 和 CLI 包装命令。原始 socket 客户端要自己负责使用平台原生的本地 socket 形式。 + +## 智能体状态上报 + +集成用 `pane.report_agent` 上报智能体状态。 + +```json +{ + "id": "req_1", + "method": "pane.report_agent", + "params": { + "pane_id": "w1:p1", + "source": "custom:docs", + "agent": "docs-bot", + "state": "working", + "message": "building docs" + } +} +``` + +`state` 是语义性的。它影响等待、通知和汇总。只影响展示的值应通过元数据单独上报。 + +仅提供会话的官方集成用 `pane.report_agent_session` 上报原生会话引用。上报状态的集成仍然可以在 `pane.report_agent` 中包含原生会话引用。与状态无关的会话上报不影响等待、通知或汇总。 + +```json +{ + "id": "req_2", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "herdr:codex", + "agent": "codex", + "agent_session_id": "..." + } +} +``` + +Herdr 存有原生会话引用时,`pane.get`、`pane.list`、`agent.get` 和 `agent.list` 暴露一个只读的 `agent_session` 对象: + +```json +{ + "agent_session": { + "source": "herdr:codex", + "agent": "codex", + "kind": "id", + "value": "..." + } +} +``` + +没有存储原生会话引用时,该字段被省略。 + +当 Herdr 能解析当前控制窗格 PTY 的进程的 cwd 时,`pane.get`、`pane.list`、`agent.get` 和 `agent.list` 也暴露 `foreground_cwd`。已有的 `cwd` 字段仍然是用于标签、follow-cwd 行为和恢复会话状态的窗格/工作区 cwd。 + +`PaneInfo` 和 `AgentInfo` 暴露可选的 `terminal_title` 和 `terminal_title_stripped` 字段。`terminal_title` 是经过安全规范化的最新 OSC 0/2 标题。`terminal_title_stripped` 会移除开头一个已识别的活动或旋转指示符字形及其后的空白。这些值归服务器所有,冷重启后不会保留,并且独立于元数据 `title` 和语义智能体状态。 + +当用户钩子想自定义展示、又不从 Herdr 集成接管生命周期状态时,使用 `pane.report_metadata`。 + +```json +{ + "id": "req_2", + "method": "pane.report_metadata", + "params": { + "pane_id": "w1:p1", + "source": "user:claude-title", + "agent": "claude", + "title": "Refactor auth middleware", + "display_agent": "Claude: auth", + "state_labels": { + "working": "refactoring auth", + "idle": "ready", + "done": "review ready" + }, + "tokens": { + "summary": "refactor auth", + "model": "opus" + }, + "ttl_ms": 3600000 + } +} +``` + +元数据上报只影响展示。有效的元数据可以覆盖窗格标题、显示的智能体名称、可见的状态标签和任意命名令牌。`working`、`blocked`、`idle`、等待、通知和汇总仍来自语义状态。原生会话恢复来自存储的官方会话引用。`agent` 是展示字段针对权威智能体标签的可选守卫;`applies_to_source` 同样保护展示字段,使其只适用于活动生命周期权威来源。这些守卫不作用于令牌补丁;令牌上报方负责清除和刷新 TTL。用 `display_agent` 修改可见名称。`state_labels` 的键必须是 `idle`、`working`、`blocked`、`done` 或 `unknown`。 + +令牌映射是针对每个资源的补丁。字符串设置键,JSON `null` 清除键,未提及的键保持不变。最后接受的更新生效。可选 TTL 分别应用于该次上报更新的每个令牌键。窗格令牌会在 pane 和 agent 的 get/list 响应中公开,并可在智能体侧边栏行中写成 `$name`。单次上报最多可包含 16 个令牌键,每个窗格或工作区最多保留 32 个键。令牌名称必须由 1–32 个 ASCII 字母、数字、下划线或连字符组成。 + +工作区令牌使用相同约定: + +```json +{"id":"req_3","method":"workspace.report_metadata","params":{"workspace_id":"w1","source":"user:jj","tokens":{"jj_status":"2 changes","old":null},"ttl_ms":5000}} +``` + +workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏行可显示 `$jj_status` 这类值。变更和 TTL 到期会发出包含最新工作区快照的 `workspace.metadata_updated`。该元数据事件会发送给 API 订阅者,但不会调用插件事件钩子。 + +展示文本在存储前被规范化。Herdr 去掉首尾空白、移除控制字符,并把 `title`、`display_agent`、每个状态标签和令牌值截断到 80 个字符。规范化后为空的令牌值会清除该键。 + +`source` 和 `applies_to_source` 是来源标识符。它们必须不超过 80 个字符,且只能包含 ASCII 字母、数字、冒号、点、下划线和连字符。 + +短期元数据用 `ttl_ms`。取值必须在 `1` 到 `86400000` 毫秒之间。想让元数据保留到被替换、清除或窗格或工作区关闭时,省略 `ttl_ms`。展示字段继续按来源过期;该次调用更新的每个令牌都有独立期限。令牌元数据不会在服务器重启后恢复。 + +钩子可能乱序发送更新时,使用 `seq`。对同一 `source`,序号小于等于最后接受序号的上报会被 API 接受,但被窗格状态忽略。每个窗格或工作区在其生命周期内最多接受来自 32 个不同来源的带序号令牌上报;清除或过期不会释放这些来源名额。 + +## 事件订阅 + +需要长期数据流时订阅事件: + +```json +{ + "id": "sub_1", + "method": "events.subscribe", + "params": { + "subscriptions": [ + { "type": "pane.agent_status_changed", "pane_id": "w1:p1", "agent_status": "blocked" } + ] + } +} +``` + +第一个响应确认订阅。之后的行是推送的事件。生命周期事件订阅从请求被接受时开始,不会重放在此之前保留的事件。 + +工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。`workspace.reordered` 包含原子移动的 `workspace_ids`、可选的 `before_workspace_id` 和服务器确认的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。 +标签页事件订阅包括 `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed` 和 `tab.moved`。`tab.moved` 包含被移动的 `tab_id`、`workspace_id`、请求的 `insert_index` 和该工作区更新后的有序 `tabs` 列表。 +窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。`pane.focused` 也会上报任意连接到此服务器的客户端手动选择窗格的变化,不会移动其他客户端的视图,且载荷不包含客户端身份。重复选择已选中的窗格不会再次发出事件。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。 +布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 引导的客户端应替换具有相同 `workspace_id` 和 `tab_id` 的缓存布局。 + +Worktree 事件订阅包括 `worktree.created`、`worktree.opened` 和 `worktree.removed`。Worktree 事件描述 Git 检出的生命周期。`worktree.created` 包含打开的 `workspace` 和创建的 `worktree`。`worktree.opened` 包含目标 `workspace`、打开的 `worktree` 和 `already_open`。`worktree.removed` 包含 `workspace_id`、被移除的 `worktree` 和 `forced`。 + +生命周期事件用 `events.subscribe`。支持一次性等待时,专门的等待辅助命令会单独在文档中说明。 + +## 读取窗格 + +除非你在编写协议客户端,否则通过 CLI 使用 `pane.read`。 + +```bash +herdr pane read w1:p1 --source visible --lines 80 +herdr pane read w1:p1 --source recent --lines 120 +herdr pane read w1:p1 --source recent-unwrapped --lines 120 +herdr pane read w1:p1 --source detection +``` + +`recent-unwrapped` 对日志很有用,因为它忽略软折行。 +`detection` 返回智能体屏幕检测使用的底部缓冲区快照。 + +## 等待状态 + +用等待来协调智能体和脚本。 + +```bash +herdr agent wait w1:p1 --until done +herdr agent wait w1:p1 --until blocked +``` + +智能体等待观察的是语义状态,不是任意命令的完成。 + +## 响应结构 + +成功响应长这样: + +```json +{ + "id": "req_1", + "result": { + "type": "pane_info", + "pane": { + "pane_id": "w1:p1", + "terminal_id": "term_abc123", + "workspace_id": "w1", + "tab_id": "w1:t1", + "focused": true, + "agent_status": "working", + "revision": 42 + } + } +} +``` + +`server.agent_manifests` 返回生效的智能体检测清单来源和远程更新诊断信息,不重载规则: + +```json +{ + "id": "req_1", + "result": { + "type": "agent_manifest_status", + "last_check_unix": 1781043522, + "last_result": "checked", + "manifests": [ + { + "agent": "cursor", + "source": "/home/me/.config/herdr/agent-detection/cursor.toml", + "source_kind": "local override", + "active_version": "2026.06.10.1", + "cached_remote_version": "2026.06.10.1", + "local_override_shadowing_remote": true, + "remote_update_result": "current" + } + ] + } +} +``` + +`last_check_unix`、`last_result`、`active_version`、`cached_remote_version`、`remote_update_result`、`remote_update_error`、`remote_last_checked_unix` 和 `warning` 这类字段在不可用时被省略。`server.reload_agent_manifests` 在重载内存中的规则缓存后,返回带相同 `manifests` 条目结构的 `agent_manifest_reload`。 + +`agent.explain` 使用服务器生效的清单缓存,在运行中的服务器上评估目标窗格的检测快照: + +```json +{ + "id": "req_2", + "method": "agent.explain", + "params": { "target": "w1:p1" } +} +``` + +响应包含与 `herdr agent explain --json` 打印的相同的 explain 对象,包括最终状态、清单来源和版本、匹配的规则、已评估规则的证据、跳过状态原因、idle 回退原因,以及当完整生命周期钩子权威使屏幕规则不再权威时的 `screen_detection_skip_reason`。 + +客户端需要一个支持 `agent.explain` 的运行中服务器;升级 Herdr 后,请先重启或实时交接服务器,再依赖此方法。 + +错误长这样: + +```json +{ + "id": "req_1", + "error": { + "code": "not_found", + "message": "pane not found" + } +} +``` + +## 协议稳定性 + +由客户端渲染的 Herdr UI 对本地和 SSH 服务器使用稳定的端点代数。客户端和服务器构建不必一致。连接时,双方会协商核心快照、屏幕、输入和 blob 编解码器,服务器则公布支持的 API 方法和可选能力。已保存的 SSH 机器需要 `surface_interest` 来确保只有选中的机器传输窗格画面,并需要 `health_check` 来发现空闲连接的故障。缺少这些能力的服务器会显示 Attention,直到明确更新。详见[连接机器](/zh-cn/docs/connecting-machines/)。缺少方法时,只禁用该服务器上的对应操作并显示客户端本地通知;操作被拒绝或超时也不会断开连接。只有早于端点第 1 代的服务器需要最后更新一次。 + +带编号的二进制协议仍用于同一安装内的内部操作,包括直接终端连接和实时交接。跨不同构建使用这些操作前,请检查 `ping` 或 `herdr status`。JSON API 客户端应忽略未知字段,并把不支持的方法当作普通错误处理。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/troubleshooting.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/troubleshooting.mdx new file mode 100644 index 00000000..3d25e9e1 --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/troubleshooting.mdx @@ -0,0 +1,120 @@ +--- +title: 故障排除 +description: 诊断常见的安装、终端输入、会话、快捷键和远程连接问题。 +--- + +先检查版本和会话状态: + +```bash +herdr -V +herdr status +``` + +同时记录操作系统、外层终端名称和版本、会话是本地还是远程,以及是否使用了 tmux。 + +## Windows 上的 CJK 输入法窗口位置错误或光标闪烁 + +Herdr 在原生 Windows 和 WSL 上默认使用单元格绘制光标,因为 ConPTY 多路复用器重绘屏幕时,原生光标可能闪烁、跳动或显示旧位置。单元格绘制光标无法提供韩语、日语和中文输入法预编辑文本及候选窗口所需的原生光标定位。 + +如果输入法预编辑文本或候选窗口出现在错误位置,请启用原生光标: + +```toml +[ui] +host_cursor = "native" +``` + +原生模式会恢复输入法定位,但在持续输出期间可能再次出现偶发的光标移动异常。要恢复视觉稳定的光标,请设置 `host_cursor = "drawn"`,或删除该设置以使用 Windows 默认值。当前限制详见 [Windows 支持](/zh-cn/docs/windows-beta/)。 + +## Enter、Tab 或 Backspace 触发两次 + +旧版终端在应用启用 Kitty 键盘事件报告后,可能把 Enter、Tab 和 Backspace 的释放事件发送为与按下事件相同的字节。终端发送后,Herdr 无法区分这些重复字节。 + +请把外层终端更新到包含上游修复的版本: + +| 终端 | 最低修复版本 | +| --- | --- | +| kitty | 0.33.0 | +| foot | 1.20.0 | +| Alacritty | 0.15.0 | + +长期支持版 Linux 发行版中的旧终端软件包尤其容易出现此问题。已确认的边界捕获和上游链接见 [Herdr issue #1116](https://github.com/herdrdev/herdr/issues/1116)。如果当前版本仍有问题,请报告准确的终端版本,以及该问题是否也会在 Herdr 外出现。 + +## 按 Option+Left 或 Option+Right 时输入 `;3D` 或 `;3C` + +终端通常把 Alt+Left 和 Alt+Right 发送为标准的带修饰键方向键序列 `ESC[1;3D` 和 `ESC[1;3C`。在 macOS 上,需要先把外层终端配置为将 Option 视为 Alt。如果 shell 没有绑定这些序列,zsh 可能会把剩余字符显示为 `;3D` 或 `;3C`。kitty、Alacritty 和其他终端都可能出现这种情况;Herdr 和 tmux 都会保留原始的带修饰键方向键输入。 + +如果希望带修饰键的方向键在所有终端和嵌套 shell 中都执行按单词移动,请添加明确的 zsh 绑定: + +```zsh +bindkey $'\e[1;3D' backward-word +bindkey $'\e[1;3C' forward-word +``` + +kitty 在 Herdr 外可能表现不同,因为它的自动 zsh 集成只会为 kitty 直接启动的 shell 添加这些绑定,而不会为终端多路复用器创建的 shell 添加。请按照 kitty 的[手动 shell 集成说明](https://sw.kovidgoyal.net/kitty/shell-integration/#manual-shell-integration),或在按键到达 shell 前通过 `kitty.conf` 映射它们: + +```text +map alt+left send_text all \x1bb +map alt+right send_text all \x1bf +``` + +终端应用可能会直接使用 Alt+Left 和 Alt+Right,因此 Herdr 不会改写带修饰键的方向键。调查详情见 [Herdr issue #1370](https://github.com/herdrdev/herdr/issues/1370)。 + +## Herdr 已更新,但运行中的会话仍是旧版本 + +更新二进制文件不会替换正在运行的兼容服务器。重新打开 Herdr 即可使用更新后的客户端。只有还需要本次发行的服务器功能时,才先检查 `herdr status`,然后停止会话并重新启动 Herdr: + +```bash +herdr server stop +herdr +``` + +停止服务器会结束窗格进程。命名会话使用 `herdr session stop <name>`。更新器、软件包管理器和实时移交行为见[安装 Herdr](/zh-cn/docs/install/#update)。 + +## macOS 上依赖钥匙串的工具仅在 Herdr 内失败 + +在 Herdr 窗格中检查服务器的启动上下文: + +```bash +launchctl managername +``` + +自动启动的 macOS 服务器显示 `Background` 是正常的,不能仅凭此判断钥匙串访问有问题。如果钥匙串访问失败,且服务器是通过 SSH 或后台任务启动的,请停止服务器,然后从普通 GUI 终端重新启动 Herdr: + +```bash +herdr server stop +herdr +``` + +停止服务器会结束窗格进程。Herdr 窗格会继承长期运行服务器的 macOS 启动上下文,因此通过 SSH 或后台任务启动的服务器可能无法访问交互式钥匙串服务。详情见 [Herdr issue #966](https://github.com/herdrdev/herdr/issues/966)。 + +## macOS 注销后 SSH 或 DNS 失败 + +自动启动的 macOS 服务器使用用户级服务上下文,避免注销后窗格与 macOS 服务的连接失效。显式运行 `herdr server` 时仍会保留调用方的上下文。 + +旧服务器可能在注销后继续运行,但无法查询用户信息或解析 DNS。症状包括 `No user exists for uid 501`、`Could not get manager name.` 和 `curl: (6) Could not resolve host`。重新登录不能修复该服务器。请停止受影响的会话,然后从新登录会话的 GUI 终端启动 Herdr。这会结束正在运行的窗格进程,但会恢复保存的会话布局。实时移交会保留源服务器的服务上下文和现有窗格进程。因此,旧服务器需要完全重启会话才能应用此修复;移交无法修复继承的上下文。 + +如果新启动的服务器仍出现此问题,请在 `herdr-server.log` 中查找 `could not select persistent user service context`。所需的 macOS API 不可用时,Herdr 会保留继承的上下文。详情见 [Herdr issue #4100](https://github.com/herdrdev/herdr/issues/4100)。 + +## 找不到 `herdr` 命令 + +重启终端以重新加载环境,然后确认 Herdr 安装目录位于 `PATH` 中。通过软件包管理器安装的 Herdr 必须通过该管理器更新并加入环境。见[安装 Herdr](/zh-cn/docs/install/#verify)。 + +## 直接快捷键没有反应 + +操作系统或外层终端可能在 Herdr 收到按键前就拦截了组合键。请在对应层释放该组合键,或改用其他绑定。已知冲突和安全默认值见[键盘](/zh-cn/docs/keyboard/#going-prefix-free)。 + +## 远程连接无法认证 + +先用 `ssh <host>` 确认普通 OpenSSH 连接正常。若在非交互 shell、CI 或移动终端中使用带密码的密钥,请在远程连接前把密钥载入 `ssh-agent`。见[持久化和远程访问](/zh-cn/docs/persistence-remote/#remote-attach-over-ssh)。 + +## 查找诊断日志 + +Herdr 日志默认位于 `~/.config/herdr/`: + +```text +herdr.log +herdr-client.log +herdr-server.log +``` + +设置 `HERDR_LOG=herdr=debug` 可获得更多细节。报告问题时请附上当前日志和轮转后的日志。见[配置](/zh-cn/docs/configuration/#logs)。 diff --git a/docs/versions/0.9.1/website/src/content/docs/zh-cn/windows-beta.mdx b/docs/versions/0.9.1/website/src/content/docs/zh-cn/windows-beta.mdx new file mode 100644 index 00000000..025f0f4d --- /dev/null +++ b/docs/versions/0.9.1/website/src/content/docs/zh-cn/windows-beta.mdx @@ -0,0 +1,147 @@ +--- +title: Windows 支持 +description: Windows 原生支持、受支持的工作流和已知限制。 +--- + +Windows 原生支持已正式发布。 + +Windows 上的 Herdr 使用 ConPTY 和 Windows 的进程/运行时行为,而不是 Herdr 最初围绕构建的 Unix PTY 模型。大多数核心工作流已受支持,但部分能力与 Linux/macOS 不同或取决于平台。随着这些剩余差距逐步缩小,Windows 可能会收到更多平台专用修复。 + +使用 PowerShell 在 Windows 上原生安装 Herdr: + +```powershell +powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex" +``` + +如果端点安全软件阻止该无文件 PowerShell 命令,请打开命令提示符并运行: + +```cmd +curl.exe -fsSLo install.cmd https://herdr.dev/install.cmd && install.cmd && del install.cmd +``` + +Windows 构建同时通过稳定版和预览版更新通道提供。新安装默认使用稳定版,常规使用也建议选择稳定版。现有预览安装会继续留在预览通道,直到你运行 `herdr channel set stable`。如果旧预览构建拒绝该命令,请先在预览通道运行一次 `herdr update`,再重试。预览版会提供来自 `master` 的更新、测试较少的修复,也可能出现回归;只有在接受这一取舍时,才应通过 `herdr channel set preview` 主动启用。 + +安装器将发布版本保存在 `%USERPROFILE%\.herdr\packages\standalone\releases` 下,把当前带版本号的发布目录加入 PATH,将 `%LOCALAPPDATA%\Programs\Herdr\bin` 保留为稳定的兼容别名,并保留少量旧版本,以免运行中的进程阻塞更新。 + +对于内部测试,`HERDR_MANIFEST_URL` 可以让安装器指向自定义清单,而不是 Herdr 的稳定或预览清单。使用自定义预览清单时还应设置 `HERDR_CHANNEL=preview`。 + +## Windows 支持 + +| 能力 | 状态 | +| --- | --- | +| 本地持久会话 | 已支持 | +| 通过 ConPTY 的原生窗格 | 已支持 | +| Windows Terminal / PowerShell 应用连接 | 已支持 | +| 连接 Linux/macOS/Windows 主机的 `herdr --remote` 和已保存 SSH 机器 | 已支持;交互式连接和机器设置可在确认后安装或更新 Windows 软件包,后台重连只查找已安装的软件包 | +| 远程剪贴板图像和图像文件拖放 | 已支持 | +| `cmd.exe` 窗格 | 已支持 | +| 原生键盘和鼠标输入 | 已支持 | +| 启动 cwd 和工作区标签 | 已支持 | +| 窗格启动 cwd | 已支持 | +| 智能体命令发现 | 已支持 | +| 已支持智能体的自报告集成 | 已支持 | +| 智能体进程树检测 | 已支持 | +| 从已知 cwd 检测 Git/worktree | 已支持 | +| 系统通知和 MP3 声音 | 已支持 | +| 插件 | 预览 | +| 窗格屏幕历史 | 已支持 | +| 嵌套启动覆盖 | 已支持 | + +本地持久会话会在客户端分离或终端窗口关闭后继续运行。通过 Windows OpenSSH 启动的服务器和窗格进程也会在注销后继续运行;再次运行 `herdr` 即可重新连接。 + +Windows 智能体进程检测会扫描窗格 shell 的后代进程,识别直接运行的智能体和常见的命令包装器,包括 npm/Node 和 Git Bash 进程链。它会跨越模拟的 `exec` 边界跟踪从 Git Bash 启动的智能体,但这和 Unix 前台进程组检测不是一回事。 + +Windows 目前支持安装 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、OpenCode、Kilo Code CLI、Droid、Kimi Code CLI、Qoder CLI 和 Antigravity CLI 集成。可用范围比 Unix 更窄;安装格式不受 Windows 支持的集成会被隐藏或拒绝。 + +插件以预览状态支持在清单中声明 `windows` 平台。GitHub 安装、本地链接、构建命令、动作、事件和插件窗格在 Windows 上尽力而为。命令是 argv 命令,必须兼容 Windows;`npm`、`bun`、`node` 之类的 Node 包 shim 只要在 `PATH` 上就应该能用,而使用 `sh` 或 Bash 的 Unix 专用示例需要 Windows 专用的替代方案。平台过滤器会跳过不支持的构建命令,并对不支持的动作或窗格返回 `platform_unsupported`。 + +## 部分支持 + +| 能力 | 状态 | +| --- | --- | +| shell `cd` 之后的实时 cwd | 部分支持 | +| 通过 shell 集成/OSC7 的实时 cwd | 已支持 | +| 向本地窗格中的智能体粘贴剪贴板图像 | 取决于终端/智能体 | +| CJK 输入法候选窗口定位 | 部分支持 | +| 前缀输入源切换 (韩语输入法) | 部分支持 | +| Kitty graphics 渲染 | 取决于终端 | +| 主机光标渲染 | 部分支持 | + +Herdr 可以在正确的目录中启动窗格,并能从你启动 Herdr 的目录创建初始工作区。启动之后 PowerShell 的目录变化则不同: Herdr 能检查的进程字段无法可靠跟踪后续的逻辑 `cd` 变化。实时 cwd 上报请使用 Herdr 集成或提示符 shell 集成。 + +使用 `herdr --remote` 时,配置的远程图像粘贴键会读取 Windows 剪贴板图像并传输到远程主机。把一个本地图像文件拖入 Windows Terminal 也会传输该文件并粘贴远程路径。 + +前缀输入源切换可作为针对韩语输入法的选择性实验功能使用。前缀命令处于活动状态时,它会把韩文输入切换为英文,之后再恢复先前的模式: + +```toml +[experimental] +switch_ascii_input_source_in_prefix = true +``` + +此选项暂不支持其他 Windows 输入法。参见[配置](/zh-cn/docs/configuration/#前缀输入源切换)。 + +Windows 上的部分智能体可以接收 `ctrl+v` 并直接读取剪贴板图像。Herdr 自己的剪贴板图像读取器尚未接入本地原生 Windows 窗格,因此智能体原生图像粘贴仍取决于终端和智能体。`alt+v` 等智能体图像粘贴快捷键不会增加由 Herdr 管理的本地剪贴板桥。远程剪贴板图像桥通过连接 Linux/macOS/Windows 主机的 `herdr --remote` 单独受支持。 + +Kitty graphics 默认启用,并取决于外层终端。Herdr 会像在其他平台上一样在 Windows 上输出 Kitty graphics protocol。这个路径已在 Windows WezTerm 托管 WSL 中 Herdr 的配置下得到验证,但原生 Windows 的各种终端与 ConPTY 组合尚未全部验证。Windows Terminal 不提供 Herdr 使用的 Kitty graphics 路径。如果外层终端无法正确处理图形输出,请设置 `[terminal].kitty_graphics = false`。 + +## 已知注意事项 + +### 光标渲染 + +Herdr 的原生 Windows 窗格依赖 ConPTY。当前 Windows 终端的光标处理可能会在多路复用器重绘屏幕时显示中间光标位置。因此,在持续输出期间,原生光标可能闪烁、跳动,或短暂留在旧位置。这个现象也能在其他 Windows 原生终端多路复用器以及直接连续更新 VT 光标位置的压力测试中复现,所以 Herdr 无法在保留原生光标行为的同时彻底消除它。 + +为了优先保证视觉稳定性,默认的 `host_cursor = "auto"` 会在原生 Windows 和 WSL 上把 Herdr 光标绘制为终端单元格内容。其他 Linux 和 macOS 客户端仍使用原生终端光标。Windows 上的绘制光标稳定且不闪烁,但不会使用外层终端的原生闪烁、形状或光标颜色。 + +绘制光标并不是 Windows 用来定位输入法预编辑文本和候选窗口的真实光标。因此,韩语、日语或中文输入法 UI 可能显示在错误位置。如果遇到这个问题,请改用外层终端光标: + +```toml +[ui] +host_cursor = "native" +``` + +原生模式会恢复输入法定位,但在持续输出期间可能再次出现偶发的光标闪烁、跳动或旧位置残影。这是当前 Windows 支持中的兼容性取舍。 + +### 键盘和鼠标 + +Windows 终端并不都以相同形式报告带修饰键的按键。Herdr 在 Windows Terminal 和 Windows 上的 Alacritty 中保留鼠标报告和 `ctrl+j`。原生 Windows 输入路径还会通过默认 ConPTY 窗格保留物理按键的按下、重复、释放、独立 Escape 和 `shift+enter`。带修饰键的输入仍依赖外层终端报告可区分的按键事件;如果它把 `shift+enter` 报告为普通 Enter,Herdr 也只能转发普通 Enter。 + +旧版 Windows 10 的系统 ConPTY 会丢弃 Kimi 和 Pi 等智能体使用的 Kitty 键盘协议序列,因此 Windows 包包含 Microsoft 当前的应用本地 ConPTY 运行时。仅在诊断捆绑运行时的兼容性问题时,才应在启动 Herdr 前设置 `HERDR_WINDOWS_CONPTY=system`。 + +## 复制与粘贴 + +Herdr 的窗格文本复制在 Windows 上可用。在窗格内拖选文本即可通过 Herdr 复制。 + +文本粘贴请在 Windows Terminal 中使用 `ctrl+shift+v`。多行文本粘贴是带括号的 (bracketed),因此 shell 和智能体提示符会把它作为一次粘贴接收,而不是逐行提交。按住 `shift` 再右键,可以使用外层终端的粘贴操作,而不把点击发给 Herdr。 + +## Windows 不支持 + +| 能力 | 状态 | +| --- | --- | +| 直接终端附加 (`herdr terminal attach`) | 不支持 | +| 实时服务器交接 | 不支持 | +| Unix 文件描述符交接 | 不支持 | +| Unix 前台进程组 | 不支持 | +| 本地原生窗格中的 Herdr 剪贴板图像桥 | 不支持 | +| 签名二进制 / 规避 SmartScreen | 不支持 | + +在 Windows Terminal 中,使用与 Linux 和 macOS 相同的远程命令: + +```powershell +herdr --remote workbox +``` + +目标主机可以运行 Linux、macOS 或 Windows。Windows 主机会复用 `PATH` 或当前有效的托管版本中的兼容软件包。交互式连接和保存机器设置会在安装或更新完整软件包前询问;后台重连只查找已安装的软件包,不会提示安装或更新。Herdr 使用已安装的 Windows OpenSSH 客户端和你的 SSH 配置。Windows OpenSSH 不使用 Herdr 的 Unix control socket 连接复用,因此建议通过 Windows `ssh-agent` 使用密钥认证,避免远程设置期间重复提示。 + +Windows 更新通过 Windows 安装器进行,并更新当前带版本号的发布路径。新终端和重新连接的 SSH 会话会获得该路径;在其中启动 Herdr 即可使用更新后的客户端。兼容的运行中服务器会让窗格继续运行。只有需要新版本的服务器端改动时,才稍后重启服务器。实时交接仅限 Unix。 + +## 报告 Windows 问题 + +请包含: + +- Herdr 版本。 +- Windows 版本。 +- 终端应用。 +- Shell,比如 PowerShell 或 cmd。 +- 是否使用了命名的 `HERDR_SESSION`。 +- 相关的 Herdr 日志。 +- 精确的复现步骤。 diff --git a/docs/versions/0.9.1/website/src/data/config-reference.json b/docs/versions/0.9.1/website/src/data/config-reference.json new file mode 100644 index 00000000..36afe337 --- /dev/null +++ b/docs/versions/0.9.1/website/src/data/config-reference.json @@ -0,0 +1,1522 @@ +{ + "sections": [ + { + "id": "general", + "title": "General", + "keys": [ + { + "key": "onboarding", + "type": "boolean", + "default": "unset", + "description": "Show first-run setup on startup. Missing or true shows onboarding; continuing from onboarding writes onboarding = false." + } + ] + }, + { + "id": "server", + "title": "Server", + "keys": [ + { + "key": "server.headless_cols", + "type": "integer", + "default": "120", + "description": "Virtual terminal width used for layout and newly created panes when no client is attached. Must be greater than zero." + }, + { + "key": "server.headless_rows", + "type": "integer", + "default": "40", + "description": "Virtual terminal height used for layout and newly created panes when no client is attached. Must be greater than zero." + } + ] + }, + { + "id": "theme", + "title": "Theme", + "keys": [ + { + "key": "theme.name", + "type": "string", + "default": "\"catppuccin\"", + "description": "Built-in theme name." + }, + { + "key": "theme.auto_switch", + "type": "boolean", + "default": "false", + "description": "Follow host terminal light/dark appearance and switch between theme names." + }, + { + "key": "theme.dark_name", + "type": "string", + "default": "unset", + "description": "Theme name used when `auto_switch` selects a dark appearance." + }, + { + "key": "theme.light_name", + "type": "string", + "default": "unset", + "description": "Theme name used when `auto_switch` selects a light appearance." + }, + { + "key": "theme.custom.accent", + "type": "color", + "default": "unset", + "description": "Override the accent color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.panel_bg", + "type": "color", + "default": "unset", + "description": "Override the panel_bg color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.sidebar_bg", + "type": "color", + "default": "unset", + "description": "Set the desktop sidebar background without changing other panel surfaces. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.active_row_bg", + "type": "color", + "default": "unset", + "description": "Set the active Space and focused Agent row background without changing separators or scrollbar tracks. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.selection_bg", + "type": "color", + "default": "unset", + "description": "Set the Navigate-mode cursor row background in the sidebar without changing other selection surfaces. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.surface0", + "type": "color", + "default": "unset", + "description": "Override the surface0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.surface1", + "type": "color", + "default": "unset", + "description": "Override the surface1 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.surface_dim", + "type": "color", + "default": "unset", + "description": "Override the surface_dim color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.overlay0", + "type": "color", + "default": "unset", + "description": "Override the overlay0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.overlay1", + "type": "color", + "default": "unset", + "description": "Override the overlay1 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.text", + "type": "color", + "default": "unset", + "description": "Override the text color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.subtext0", + "type": "color", + "default": "unset", + "description": "Override the subtext0 color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.mauve", + "type": "color", + "default": "unset", + "description": "Override the mauve color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.green", + "type": "color", + "default": "unset", + "description": "Override the green color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.yellow", + "type": "color", + "default": "unset", + "description": "Override the yellow color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.red", + "type": "color", + "default": "unset", + "description": "Override the red color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.blue", + "type": "color", + "default": "unset", + "description": "Override the blue color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.teal", + "type": "color", + "default": "unset", + "description": "Override the teal color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.peach", + "type": "color", + "default": "unset", + "description": "Override the peach color token on top of the base theme. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.accent", + "type": "color", + "default": "unset", + "description": "Override the accent color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.panel_bg", + "type": "color", + "default": "unset", + "description": "Override the panel_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.sidebar_bg", + "type": "color", + "default": "unset", + "description": "Override the sidebar_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.active_row_bg", + "type": "color", + "default": "unset", + "description": "Override the active_row_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.selection_bg", + "type": "color", + "default": "unset", + "description": "Override the selection_bg color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.surface0", + "type": "color", + "default": "unset", + "description": "Override the surface0 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.surface1", + "type": "color", + "default": "unset", + "description": "Override the surface1 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.surface_dim", + "type": "color", + "default": "unset", + "description": "Override the surface_dim color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.overlay0", + "type": "color", + "default": "unset", + "description": "Override the overlay0 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.overlay1", + "type": "color", + "default": "unset", + "description": "Override the overlay1 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.text", + "type": "color", + "default": "unset", + "description": "Override the text color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.subtext0", + "type": "color", + "default": "unset", + "description": "Override the subtext0 color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.mauve", + "type": "color", + "default": "unset", + "description": "Override the mauve color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.green", + "type": "color", + "default": "unset", + "description": "Override the green color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.yellow", + "type": "color", + "default": "unset", + "description": "Override the yellow color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.red", + "type": "color", + "default": "unset", + "description": "Override the red color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.blue", + "type": "color", + "default": "unset", + "description": "Override the blue color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.teal", + "type": "color", + "default": "unset", + "description": "Override the teal color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.light.peach", + "type": "color", + "default": "unset", + "description": "Override the peach color token when auto_switch selects a light appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.accent", + "type": "color", + "default": "unset", + "description": "Override the accent color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.panel_bg", + "type": "color", + "default": "unset", + "description": "Override the panel_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.sidebar_bg", + "type": "color", + "default": "unset", + "description": "Override the sidebar_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.active_row_bg", + "type": "color", + "default": "unset", + "description": "Override the active_row_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.selection_bg", + "type": "color", + "default": "unset", + "description": "Override the selection_bg color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.surface0", + "type": "color", + "default": "unset", + "description": "Override the surface0 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.surface1", + "type": "color", + "default": "unset", + "description": "Override the surface1 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.surface_dim", + "type": "color", + "default": "unset", + "description": "Override the surface_dim color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.overlay0", + "type": "color", + "default": "unset", + "description": "Override the overlay0 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.overlay1", + "type": "color", + "default": "unset", + "description": "Override the overlay1 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.text", + "type": "color", + "default": "unset", + "description": "Override the text color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.subtext0", + "type": "color", + "default": "unset", + "description": "Override the subtext0 color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.mauve", + "type": "color", + "default": "unset", + "description": "Override the mauve color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.green", + "type": "color", + "default": "unset", + "description": "Override the green color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.yellow", + "type": "color", + "default": "unset", + "description": "Override the yellow color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.red", + "type": "color", + "default": "unset", + "description": "Override the red color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.blue", + "type": "color", + "default": "unset", + "description": "Override the blue color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.teal", + "type": "color", + "default": "unset", + "description": "Override the teal color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + }, + { + "key": "theme.custom.dark.peach", + "type": "color", + "default": "unset", + "description": "Override the peach color token when auto_switch selects a dark appearance. Applied after theme.custom. Accepts hex, named colors, rgb(r,g,b), or reset aliases." + } + ] + }, + { + "id": "terminal", + "title": "Terminal", + "keys": [ + { + "key": "terminal.default_shell", + "type": "string", + "default": "\"\"", + "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh." + }, + { + "key": "terminal.shell_mode", + "type": "enum", + "default": "\"auto\"", + "description": "Startup mode for new interactive pane shells.", + "values": [ + "auto", + "login", + "non_login" + ] + }, + { + "key": "terminal.new_cwd", + "type": "enum", + "default": "\"follow\"", + "description": "CWD policy for new interactive panes, tabs, and workspaces.", + "values": [ + "follow", + "home", + "current", + "path" + ] + }, + { + "key": "terminal.kitty_graphics", + "type": "boolean", + "default": "true", + "description": "Render pane images in Kitty graphics-compatible outer terminals. Set false to disable graphics rendering and the pane graphics API. Restart the server or reattach the client after changing it. In remote sessions, server configuration controls parsing and API availability while local client configuration controls outer-terminal output." + } + ] + }, + { + "id": "update", + "title": "Updates", + "keys": [ + { + "key": "update.channel", + "type": "enum", + "default": "\"stable\" (\"preview\" for Windows preview builds)", + "description": "Update channel used by background version checks and herdr update. Stable Windows builds default to stable; existing Windows preview installs stay on preview until explicitly switched. Homebrew, mise, and Nix installs ignore the preview channel.", + "values": [ + "stable", + "preview" + ] + }, + { + "key": "update.version_check", + "type": "boolean", + "default": "true", + "description": "Check herdr.dev for new Herdr versions in the background." + }, + { + "key": "update.manifest_check", + "type": "boolean", + "default": "true", + "description": "Check herdr.dev for remote agent-detection manifest updates in the background. Bundled manifests and local overrides still apply." + } + ] + }, + { + "id": "keys", + "title": "Keybindings", + "keys": [ + { + "key": "keys.prefix", + "type": "string", + "default": "\"ctrl+b\"", + "description": "Prefix key to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\")." + }, + { + "key": "keys.help", + "type": "keybinding", + "default": "\"prefix+?\"", + "description": "Open keybinding help." + }, + { + "key": "keys.settings", + "type": "keybinding", + "default": "\"prefix+s\"", + "description": "Open settings." + }, + { + "key": "keys.new_workspace", + "type": "keybinding", + "default": "\"prefix+shift+n\"", + "description": "Create a new workspace." + }, + { + "key": "keys.new_worktree", + "type": "keybinding", + "default": "\"prefix+shift+g\"", + "description": "Create a Git worktree from the selected workspace." + }, + { + "key": "keys.open_worktree", + "type": "keybinding", + "default": "unset", + "description": "Open an existing Git worktree from the selected workspace. Unset by default." + }, + { + "key": "keys.remove_worktree", + "type": "keybinding", + "default": "unset", + "description": "Delete the selected managed worktree checkout after confirmation. Unset by default." + }, + { + "key": "keys.rename_workspace", + "type": "keybinding", + "default": "\"prefix+shift+w\"", + "description": "Rename the selected workspace." + }, + { + "key": "keys.close_workspace", + "type": "keybinding", + "default": "\"prefix+shift+d\"", + "description": "Close the selected workspace." + }, + { + "key": "keys.workspace_picker", + "type": "keybinding", + "default": "\"prefix+w\"", + "description": "Open the workspace navigation surface." + }, + { + "key": "keys.goto", + "type": "keybinding", + "default": "\"prefix+g\"", + "description": "Open the session navigator." + }, + { + "key": "keys.navigate_workspace_up", + "type": "keybinding", + "default": "\"up\"", + "description": "Move workspace selection up in navigate mode." + }, + { + "key": "keys.navigate_workspace_down", + "type": "keybinding", + "default": "\"down\"", + "description": "Move workspace selection down in navigate mode." + }, + { + "key": "keys.navigate_pane_left", + "type": "keybinding", + "default": "\"h\"", + "description": "Focus the pane to the left in navigate mode. Left arrow is always an alias." + }, + { + "key": "keys.navigate_pane_down", + "type": "keybinding", + "default": "\"j\"", + "description": "Focus the pane below in navigate mode." + }, + { + "key": "keys.navigate_pane_up", + "type": "keybinding", + "default": "\"k\"", + "description": "Focus the pane above in navigate mode." + }, + { + "key": "keys.navigate_pane_right", + "type": "keybinding", + "default": "\"l\"", + "description": "Focus the pane to the right in navigate mode. Right arrow is always an alias." + }, + { + "key": "keys.detach", + "type": "keybinding", + "default": "\"prefix+q\"", + "description": "Detach the current client from its Herdr server." + }, + { + "key": "keys.reload_config", + "type": "keybinding", + "default": "\"prefix+shift+r\"", + "description": "Reload config.toml in the running app/server." + }, + { + "key": "keys.open_notification_target", + "type": "keybinding", + "default": "\"prefix+o\"", + "description": "Focus the currently visible notification target." + }, + { + "key": "keys.previous_workspace", + "type": "keybinding", + "default": "unset", + "description": "Select the previous workspace. Unset by default." + }, + { + "key": "keys.next_workspace", + "type": "keybinding", + "default": "unset", + "description": "Select the next workspace. Unset by default." + }, + { + "key": "keys.previous_agent", + "type": "keybinding", + "default": "unset", + "description": "Focus the previous agent shown in the agent panel. Unset by default." + }, + { + "key": "keys.next_agent", + "type": "keybinding", + "default": "unset", + "description": "Focus the next agent shown in the agent panel. Unset by default." + }, + { + "key": "keys.focus_agent", + "type": "keybinding", + "default": "unset", + "description": "Focus an agent by index 1-9. Unset by default." + }, + { + "key": "keys.remote_image_paste", + "type": "string", + "default": "\"ctrl+v\"", + "description": "Local-client shortcut that sends a clipboard image to a remote Herdr session." + }, + { + "key": "keys.new_tab", + "type": "keybinding", + "default": "\"prefix+c\"", + "description": "Create a new tab in the active workspace." + }, + { + "key": "keys.rename_tab", + "type": "keybinding", + "default": "\"prefix+shift+t\"", + "description": "Rename the active tab." + }, + { + "key": "keys.previous_tab", + "type": "keybinding", + "default": "\"prefix+p\"", + "description": "Select the previous tab." + }, + { + "key": "keys.next_tab", + "type": "keybinding", + "default": "\"prefix+n\"", + "description": "Select the next tab." + }, + { + "key": "keys.move_tab_previous", + "type": "keybinding", + "default": "unset", + "description": "Move the active tab one position toward the front. Unset by default." + }, + { + "key": "keys.move_tab_next", + "type": "keybinding", + "default": "unset", + "description": "Move the active tab one position toward the back. Unset by default." + }, + { + "key": "keys.switch_tab", + "type": "keybinding", + "default": "\"prefix+1..9\"", + "description": "Switch to tab 1-9." + }, + { + "key": "keys.switch_workspace", + "type": "keybinding", + "default": "unset", + "description": "Switch to workspace 1-9 from prefix mode. Unset by default." + }, + { + "key": "keys.close_tab", + "type": "keybinding", + "default": "\"prefix+shift+x\"", + "description": "Close the active tab." + }, + { + "key": "keys.rename_pane", + "type": "keybinding", + "default": "\"prefix+shift+p\"", + "description": "Rename the focused pane." + }, + { + "key": "keys.edit_scrollback", + "type": "keybinding", + "default": "\"prefix+e\"", + "description": "Open the focused pane scrollback in $EDITOR." + }, + { + "key": "keys.copy_mode", + "type": "keybinding", + "default": "\"prefix+[\"", + "description": "Enter keyboard copy mode for the focused pane." + }, + { + "key": "keys.focus_pane_left", + "type": "keybinding", + "default": "\"prefix+h\"", + "description": "Focus the pane to the left." + }, + { + "key": "keys.focus_pane_down", + "type": "keybinding", + "default": "\"prefix+j\"", + "description": "Focus the pane below." + }, + { + "key": "keys.focus_pane_up", + "type": "keybinding", + "default": "\"prefix+k\"", + "description": "Focus the pane above." + }, + { + "key": "keys.focus_pane_right", + "type": "keybinding", + "default": "\"prefix+l\"", + "description": "Focus the pane to the right." + }, + { + "key": "keys.swap_pane_left", + "type": "keybinding", + "default": "\"prefix+shift+h\"", + "description": "Swap the focused pane with the pane to the left." + }, + { + "key": "keys.swap_pane_down", + "type": "keybinding", + "default": "\"prefix+shift+j\"", + "description": "Swap the focused pane with the pane below." + }, + { + "key": "keys.swap_pane_up", + "type": "keybinding", + "default": "\"prefix+shift+k\"", + "description": "Swap the focused pane with the pane above." + }, + { + "key": "keys.swap_pane_right", + "type": "keybinding", + "default": "\"prefix+shift+l\"", + "description": "Swap the focused pane with the pane to the right." + }, + { + "key": "keys.cycle_pane_next", + "type": "keybinding", + "default": "\"prefix+tab\"", + "description": "Cycle to the next pane." + }, + { + "key": "keys.cycle_pane_previous", + "type": "keybinding", + "default": "\"prefix+shift+tab\"", + "description": "Cycle to the previous pane." + }, + { + "key": "keys.last_pane", + "type": "keybinding", + "default": "unset", + "description": "Focus the last focused pane across workspaces and tabs. Unset by default." + }, + { + "key": "keys.split_vertical", + "type": "keybinding", + "default": "\"prefix+v\"", + "description": "Split pane vertically (side by side)." + }, + { + "key": "keys.split_horizontal", + "type": "keybinding", + "default": "\"prefix+minus\"", + "description": "Split pane horizontally (stacked)." + }, + { + "key": "keys.close_pane", + "type": "keybinding", + "default": "\"prefix+x\"", + "description": "Close the focused pane." + }, + { + "key": "keys.zoom", + "type": "keybinding", + "default": "\"prefix+z\"", + "description": "Toggle zoom for the focused pane. The legacy key name `fullscreen` is accepted as an alias." + }, + { + "key": "keys.resize_mode", + "type": "keybinding", + "default": "\"prefix+r\"", + "description": "Enter resize mode." + }, + { + "key": "keys.resize_pane_left", + "type": "keybinding", + "default": "unset", + "description": "Resize the focused pane toward the left. Unset by default." + }, + { + "key": "keys.resize_pane_down", + "type": "keybinding", + "default": "unset", + "description": "Resize the focused pane downward. Unset by default." + }, + { + "key": "keys.resize_pane_up", + "type": "keybinding", + "default": "unset", + "description": "Resize the focused pane upward. Unset by default." + }, + { + "key": "keys.resize_pane_right", + "type": "keybinding", + "default": "unset", + "description": "Resize the focused pane toward the right. Unset by default." + }, + { + "key": "keys.toggle_sidebar", + "type": "keybinding", + "default": "\"prefix+b\"", + "description": "Toggle sidebar collapse." + }, + { + "key": "keys.indexed.tabs", + "type": "string", + "default": "unset", + "description": "Modifier combo for tab shortcuts 1-9. Unset by default." + }, + { + "key": "keys.indexed.workspaces", + "type": "string", + "default": "unset", + "description": "Modifier combo for workspace shortcuts 1-9. Unset by default." + }, + { + "key": "keys.indexed.agents", + "type": "string", + "default": "unset", + "description": "Modifier combo for agent shortcuts 1-9. Unset by default." + } + ] + }, + { + "id": "ui", + "title": "UI and sidebar", + "keys": [ + { + "key": "ui.sidebar_width", + "type": "integer", + "default": "26", + "description": "Default expanded sidebar width in columns. Auto-scales based on workspace names." + }, + { + "key": "ui.sidebar_min_width", + "type": "integer", + "default": "18", + "description": "Minimum sidebar width (columns) when expanded." + }, + { + "key": "ui.sidebar_max_width", + "type": "integer", + "default": "36", + "description": "Maximum sidebar width (columns) when expanded." + }, + { + "key": "ui.sidebar_start_collapsed", + "type": "boolean", + "default": "false", + "description": "Start Herdr with the sidebar collapsed. Changes take effect on the next launch." + }, + { + "key": "ui.sidebar_collapsed_mode", + "type": "enum", + "default": "compact", + "description": "Collapsed sidebar presentation.", + "values": [ + "compact", + "hidden" + ] + }, + { + "key": "ui.mobile_width_threshold", + "type": "integer", + "default": "64", + "description": "Terminal width at or below which Herdr uses the mobile single-column layout." + }, + { + "key": "ui.mouse_capture", + "type": "boolean", + "default": "true", + "description": "Capture mouse input for Herdr's mouse UI." + }, + { + "key": "ui.copy_on_select", + "type": "boolean", + "default": "true", + "description": "Automatically copy text selected by mouse drag or double-click. When disabled, Ctrl+C or a host-forwarded Cmd+C copies and clears the retained selection." + }, + { + "key": "ui.host_cursor", + "type": "enum", + "default": "auto", + "description": "Host cursor policy.", + "values": [ + "auto", + "native", + "drawn" + ] + }, + { + "key": "ui.right_click_passthrough_modifier", + "type": "string", + "default": "\"\"", + "description": "Modifier that lets right-click gestures pass through to pane apps. Empty disables it. Accepts ctrl, alt, cmd, super, meta, hyper, or a + separated combination; shift is rejected because many terminals reserve Shift+mouse." + }, + { + "key": "ui.redraw_on_focus_gained", + "type": "boolean", + "default": "true", + "description": "Force a full host-terminal redraw when the outer terminal regains focus." + }, + { + "key": "ui.mouse_scroll_lines", + "type": "integer", + "default": "3", + "description": "Lines to scroll per mouse wheel notch." + }, + { + "key": "ui.confirm_close", + "type": "boolean", + "default": "true", + "description": "Ask for confirmation before closing a workspace." + }, + { + "key": "ui.prompt_new_tab_name", + "type": "boolean", + "default": "true", + "description": "Ask for a tab name before creating a new tab." + }, + { + "key": "ui.prompt_new_workspace_name", + "type": "boolean", + "default": "false", + "description": "Ask for a workspace name before interactive TUI creation." + }, + { + "key": "ui.pane_borders", + "type": "enum", + "default": "\"auto\"", + "description": "Draw borders around split panes. auto draws them only for split panes, always also frames a lone pane (only while pane_outer_borders is enabled, since every edge of a lone pane is an outer edge), off disables them. Legacy booleans still parse: true maps to auto and false maps to off.", + "values": [ + "auto", + "always", + "off" + ] + }, + { + "key": "ui.pane_outer_borders", + "type": "boolean", + "default": "true", + "description": "Draw borders along the outside edge of the pane area. Disable with pane gaps disabled for tmux-style internal splitters without an outside frame." + }, + { + "key": "ui.pane_scrollbars", + "type": "boolean", + "default": "true", + "description": "Draw interactive scrollbars beside terminal panes. Disable to reclaim the scrollbar column and keep it out of terminal-native selections." + }, + { + "key": "ui.pane_gaps", + "type": "boolean", + "default": "true", + "description": "Keep split panes visually separated instead of sharing divider borders." + }, + { + "key": "ui.show_agent_labels_on_pane_borders", + "type": "boolean", + "default": "false", + "description": "Show agent labels in split pane borders when no manual pane label is set." + }, + { + "key": "ui.hide_tab_bar_when_single_tab", + "type": "boolean", + "default": "false", + "description": "Hide the tab row when the workspace has one tab." + }, + { + "key": "ui.tab_bar_position", + "type": "enum", + "default": "\"top\"", + "description": "Place the desktop tab row above or below the terminal panes.", + "values": [ + "top", + "bottom" + ] + }, + { + "key": "ui.tab_bar_right", + "type": "array", + "default": "[]", + "description": "Configure ordered right-aligned tab bar entries. Supported types are zoom, hostname, datetime, text, and command.", + "values": [ + "zoom", + "hostname", + "datetime", + "text", + "command" + ] + }, + { + "key": "ui.tab_bar_right_separator", + "type": "string", + "default": "\" \"", + "description": "Text inserted between visible right-aligned tab bar entries." + }, + { + "key": "ui.window_title", + "type": "string", + "default": "\"{hostname}: {workspace}\"", + "description": "Title Herdr writes to the terminal it runs in. Tokens: {hostname}, {workspace}, {tab}, {pane}, {terminal_title}. Empty leaves the outer title alone." + }, + { + "key": "ui.agent_panel_sort", + "type": "enum", + "default": "\"spaces\"", + "description": "Agent sidebar ordering. Saved values are \"spaces\" or \"priority\"; \"workspaces\" is accepted as an alias for \"spaces\".", + "values": [ + "spaces", + "priority" + ] + }, + { + "key": "ui.status_indicators", + "type": "enum", + "default": "\"dots\"", + "description": "Choose compact color dots or distinct static symbols for agent states.", + "values": [ + "dots", + "symbols" + ] + }, + { + "key": "ui.sidebar.agents.row_gap", + "type": "integer", + "default": "0", + "description": "Blank terminal rows between expanded Agent sidebar entries. Set to 1 to restore the previous spacing." + }, + { + "key": "ui.sidebar.agents.rows", + "type": "list of token rows", + "default": "[[\"state_icon\", \"machine\", \"workspace\", \"tab\"], [\"agent\"]]", + "description": "Default expanded Agent sidebar layout. Entries may be token strings or inline { token, fg, bold, dim, rules } style tables. Text-valued built-ins and $name metadata tokens accept up to 16 ordered rules using equals, contains, starts_with, gt, or lt; the first match overrides specified styles. Text conditions optionally accept ignore_case for ASCII matching; numeric conditions require full finite numbers. state_icon accepts fixed styles only. At most 16 rows and 16 tokens per row." + }, + { + "key": "ui.sidebar.agents.rows_by_agent", + "type": "table of token rows", + "default": "{}", + "description": "Complete Agent-row overrides keyed by strict canonical agent id. Agents without an override use ui.sidebar.agents.rows." + }, + { + "key": "ui.sidebar.spaces.row_gap", + "type": "integer", + "default": "0", + "description": "Blank terminal rows between worktree groups and unrelated top-level Spaces. Worktree parents and their indented children remain packed." + }, + { + "key": "ui.sidebar.spaces.rows", + "type": "list of token rows", + "default": "[[\"state_icon\", \"workspace\"], [\"branch\", \"git_status\"]]", + "description": "Expanded Space sidebar layout. Entries may be token strings or inline { token, fg, bold, dim, rules } style tables. Text-valued built-ins and $name metadata tokens accept up to 16 ordered rules using equals, contains, starts_with, gt, or lt; the first match overrides specified styles. Text conditions optionally accept ignore_case for ASCII matching; numeric conditions require full finite numbers. state_icon and git_status accept fixed styles only. At most 16 rows and 16 tokens per row." + }, + { + "key": "ui.accent", + "type": "color", + "default": "\"cyan\"", + "description": "Accent color for highlights, borders, and navigation UI. Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250))." + } + ] + }, + { + "id": "notifications", + "title": "Notifications", + "keys": [ + { + "key": "ui.toast.delivery", + "type": "enum", + "default": "\"off\"", + "description": "Popup notification delivery. off disables popups, herdr shows in-app toasts, terminal asks the outer terminal for a desktop notification, system asks the OS notification service directly.", + "values": [ + "off", + "herdr", + "terminal", + "system" + ] + }, + { + "key": "ui.toast.delay_seconds", + "type": "integer", + "default": "1", + "description": "Seconds to wait before sending finished or needs-input agent notifications. Herdr notifies only if the pane is still in the same state when the delay expires. 0 is instant; valid values are 0 through 3600." + }, + { + "key": "ui.toast.herdr.position", + "type": "enum", + "default": "\"bottom-right\"", + "description": "In-app toast position, relative to the full Herdr frame.", + "values": [ + "top-left", + "top-right", + "bottom-left", + "bottom-right" + ] + }, + { + "key": "ui.toast.clipboard.enabled", + "type": "boolean", + "default": "true", + "description": "Show the copied-to-clipboard popup after a mouse copy." + }, + { + "key": "ui.toast.clipboard.position", + "type": "enum", + "default": "\"bottom-center\"", + "description": "Copied-to-clipboard popup position.", + "values": [ + "top-left", + "top-center", + "top-right", + "bottom-left", + "bottom-center", + "bottom-right" + ] + } + ] + }, + { + "id": "sound", + "title": "Sound", + "keys": [ + { + "key": "ui.sound.enabled", + "type": "boolean", + "default": "true", + "description": "Play sounds when agents change state in background workspaces." + }, + { + "key": "ui.sound.path", + "type": "path", + "default": "unset", + "description": "Optional mp3 file path used for all notification sounds. Relative paths are resolved from the config file's directory." + }, + { + "key": "ui.sound.done_path", + "type": "path", + "default": "unset", + "description": "Optional mp3 file path for \"done\" notifications. Relative paths are resolved from the config file's directory." + }, + { + "key": "ui.sound.request_path", + "type": "path", + "default": "unset", + "description": "Optional mp3 file path for \"request\" notifications. Relative paths are resolved from the config file's directory." + }, + { + "key": "ui.sound.agents.pi", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Pi agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.claude", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Claude Code agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.codex", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Codex agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.gemini", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Gemini CLI agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.cursor", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Cursor Agent CLI agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.devin", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Devin agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.agy", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Agy agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.cline", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Cline agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.open_code", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected OpenCode agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.github_copilot", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected GitHub Copilot CLI agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.kimi", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Kimi Code CLI agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.kiro", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Kiro agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.droid", + "type": "enum", + "default": "\"off\"", + "description": "Sound override for detected Droid agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.amp", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Amp agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.grok", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Grok CLI agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.hermes", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Hermes Agent agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.kilo", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Kilo Code CLI agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.qodercli", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Qoder CLI agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.qwen", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Qwen Code agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.letta", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Letta Code agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.maki", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Maki agents.", + "values": [ + "default", + "on", + "off" + ] + }, + { + "key": "ui.sound.agents.muse", + "type": "enum", + "default": "\"default\"", + "description": "Sound override for detected Muse agents.", + "values": [ + "default", + "on", + "off" + ] + } + ] + }, + { + "id": "session", + "title": "Session", + "keys": [ + { + "key": "session.resume_agents_on_restore", + "type": "boolean", + "default": "true", + "description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session." + } + ] + }, + { + "id": "worktrees", + "title": "Worktrees", + "keys": [ + { + "key": "worktrees.directory", + "type": "string", + "default": "\"~/.herdr/worktrees\"", + "description": "Root directory under which Herdr creates <repo>/<branch-slug> checkouts." + } + ] + }, + { + "id": "remote", + "title": "Remote", + "keys": [ + { + "key": "remote.manage_ssh_config", + "type": "boolean", + "default": "true", + "description": "Add keepalive fallbacks and private connection reuse for `herdr --remote`. Set false to run plain ssh unchanged." + } + ] + }, + { + "id": "advanced", + "title": "Advanced", + "keys": [ + { + "key": "advanced.scrollback_limit_bytes", + "type": "integer", + "default": "10000000", + "description": "Maximum scrollback buffer size in bytes retained per pane terminal. The legacy key name `scrollback_lines` is accepted as an alias." + } + ] + }, + { + "id": "experimental", + "title": "Experimental", + "keys": [ + { + "key": "experimental.allow_nested", + "type": "boolean", + "default": "false", + "description": "Allow launching herdr inside an existing herdr pane." + }, + { + "key": "experimental.kitty_graphics", + "type": "boolean", + "default": "unset", + "description": "Deprecated compatibility key for terminal.kitty_graphics. Existing true and false values remain supported; terminal.kitty_graphics takes precedence when both are set." + }, + { + "key": "experimental.pane_history", + "type": "boolean", + "default": "false", + "description": "Persist pane screen history to session-history.json." + }, + { + "key": "experimental.reveal_hidden_cursor_for_cjk_ime", + "type": "boolean", + "default": "false", + "description": "Expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, so macOS native input methods keep tracking the candidate window when TUIs paint their own cursor (Claude Code, pi, codex, etc.). Default: false. When the pane reports no cursor position, falls back to the pane's top-left so a stable IME anchor is always available. Trade-off when enabled: an extra hardware cursor will be visible in the outer terminal for apps that hide the cursor without painting a replacement (vim normal mode, etc.). See #149." + }, + { + "key": "experimental.cjk_ime_agents", + "type": "list of strings", + "default": "[]", + "description": "Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose detected agent matches one of these names (case-insensitive). Empty list means apply to any focused pane. Unknown agent names are ignored; if the list contains no valid names, the reveal does not apply. Accepted names: pi, claude, codex, gemini, cursor, devin, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, qwen-code, letta, letta-code, maki." + }, + { + "key": "experimental.cjk_ime_cursor_shape", + "type": "enum", + "default": "\"steady_block\"", + "description": "Cursor shape rendered for the IME anchor when `reveal_hidden_cursor_for_cjk_ime` is enabled.", + "values": [ + "block", + "steady_block", + "underline", + "steady_underline", + "bar", + "steady_bar" + ] + }, + { + "key": "experimental.switch_ascii_input_source_in_prefix", + "type": "boolean", + "default": "false", + "description": "While prefix mode is active, temporarily switch the host input source to an ASCII-capable mode so prefix commands are read as ASCII even when an IME is active, then restore the previous input source when prefix mode exits. On macOS this selects the ASCII-capable keyboard layout; on Windows it switches the IME to English (ASCII) input. Windows support is currently limited to the Korean IME; with an IME for any other language, the input source is left unchanged. macOS and Windows only; a no-op elsewhere and a best-effort no-op if the switch fails." + } + ] + } + ] +} diff --git a/docs/versions/manifest.json b/docs/versions/manifest.json index 954c61c1..8fd9d4b2 100644 --- a/docs/versions/manifest.json +++ b/docs/versions/manifest.json @@ -1,8 +1,14 @@ { "schema_version": 1, "stable_source": "snapshot", - "current": "0.9.0", + "current": "0.9.1", "versions": [ + { + "version": "0.9.1", + "tag": "v0.9.1", + "commit": "065ef9d6a531c49fb8bee7e818ef837065b21ee9", + "source": "docs/next/website/src/content/docs" + }, { "version": "0.9.0", "tag": "v0.9.0", diff --git a/skills/herdr/SKILL.md b/skills/herdr/SKILL.md index be7b1f7c..bcb22ba8 100644 --- a/skills/herdr/SKILL.md +++ b/skills/herdr/SKILL.md @@ -74,7 +74,7 @@ Herdr injects the caller's context into each managed pane: printf '%s\n' "$HERDR_WORKSPACE_ID" "$HERDR_TAB_ID" "$HERDR_PANE_ID" ``` -Prefer `--current` when a pane command should target the calling pane. Omitting a target may use the UI-focused pane, which can belong to the user or another client. +Prefer `--current` when a pane command should target the calling pane. An omitted `pane split` target uses the calling pane when `HERDR_PANE_ID` is available, otherwise the focused pane. Other commands may use the UI-focused pane, which can belong to the user or another client. Discover live state with: @@ -88,7 +88,19 @@ herdr agent list Creation responses expose the IDs to use next. `workspace create` returns `.result.workspace`, `.result.tab`, and `.result.root_pane`. `tab create` returns `.result.tab` and `.result.root_pane`. `pane split` returns the new pane as `.result.pane`. -IDs and live agent names are scoped to one server. Two saved SSH machines can both have `w1:p1` or an agent named `reviewer`. Selecting a machine in the TUI does not retarget commands running in your pane: they still use the inherited session and socket context. Run remote control commands on the intended host with its explicit session, and rediscover IDs there. +IDs and live agent names are scoped to one server. Two saved SSH machines can both have `w1:p1` or an agent named `reviewer`. Selecting a machine in the TUI does not retarget commands running in your pane: without `--machine`, they still use the inherited session and socket context. + +To control a saved SSH machine, use the same global prefix for discovery and every later command: + +```bash +herdr --machine <label-or-id> agent list +herdr --machine <label-or-id> pane list +herdr --machine <label-or-id> agent prompt <remote-agent-name> "Reply with your current status." --wait --timeout 120000 +``` + +The selector must be an enabled saved profile ID or a unique, case-sensitive label, not an arbitrary SSH hostname. Commands use that profile's remote session without an open TUI. Do not combine `--machine` with `--session` or `--remote`. Discover IDs on that machine; inherited local IDs and `--current` do not identify remote panes. + +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`. @@ -130,7 +142,7 @@ Submit work through the agent surface: herdr agent prompt reviewer "Review the current diff and report only actionable findings." --wait --timeout 120000 ``` -`agent prompt` honors the pane's live bracketed-paste mode and sends text followed by encoded Enter as one ordered submission. It reports successful submission only after both have been written; that alone does not prove the agent started a turn. The submit delay grows with prompt size for Codex on Windows. It rejects an agent already waiting at an approval or question dialog with `agent_blocked` before sending any input. Inspect the blocked UI and ask the user before answering it. For normal agent work, `--wait` is enough: it waits for the first settled `idle`, `done`, or `blocked` state. Do not repeat those defaults with `--until`. +`agent prompt` honors the pane's live bracketed-paste mode and sends text followed by encoded Enter as one ordered submission. It reports successful submission only after both have been written; that alone does not prove the agent started a turn. For Codex on Windows, Herdr sends a paste boundary before Enter so submission does not depend on prompt size. It rejects an agent already waiting at an approval or question dialog with `agent_blocked` before sending any input. Inspect the blocked UI and ask the user before answering it. For normal agent work, `--wait` is enough: it waits for the first settled `idle`, `done`, or `blocked` state. Do not repeat those defaults with `--until`. With `--wait`, a prompt sent from a non-working state must produce observed `working` or `blocked` activity. After submission, Herdr waits up to five seconds for that activity; unrelated `idle`, `done`, or session changes do not satisfy this gate. It returns `agent_prompt_stalled` if no activity is observed, or `timeout` if the caller's timeout expires first. The caller timeout includes submission time. Without a timeout, the settled-state wait is indefinite after activity is observed. This wait tracks lifecycle state, not an individual turn; if the agent is already working, completion of the active turn may satisfy it. @@ -185,9 +197,9 @@ Use the read source that matches the task: Use `--format ansi` when colors and terminal styling are evidence. Otherwise use text. -`--lines` asks Herdr for more rows from the pane's available screen and host scrollback. If increasing it does not reveal more of a completed response, the pane is probably running the agent on the terminal's alternate screen. Rows that leave the alternate screen do not enter Herdr's host scrollback, so a larger line count cannot recover them. +`--lines` asks Herdr for more rows from the pane's available screen and host scrollback. Alternate-screen rows do not enter ordinary host scrollback. For supported idle agents, Herdr can collect application-owned history and restore the viewport afterward, but not every application or response can be recovered this way. -After that failed read, ask the agent to write its complete response as Markdown in a temporary directory and reply only with the file path, then read the file directly. Use this only as a fallback; do not request file output in the initial prompt. +If a larger recent read still does not reveal the completed response, ask the agent to write it as Markdown in a temporary directory and reply only with the file path, then read that file on the same machine. Use this only as a fallback; do not request file output in the initial prompt. ## Safety and coordination rules From 266d6f6d876ab3eb020b88ef6caee0f91776db37 Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Wed, 16 Sep 2026 23:22:37 +0300 Subject: [PATCH 03/99] fix: respect opencode tui.json registrations (#4250) refs #4240 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- .../website/src/content/docs/integrations.mdx | 2 +- src/integration/actions.rs | 4 +- src/integration/opencode_config.rs | 72 +++++++++-- src/integration/targets.rs | 14 +-- src/integration/tests.rs | 118 +++++++++++++++++- src/integration/types.rs | 3 +- 6 files changed, 187 insertions(+), 26 deletions(-) diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index e584bb06..7bca01e6 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -223,7 +223,7 @@ herdr integration install opencode The integration supports OpenCode V1 `1.18.29` or later and OpenCode V2 (tested with beta `19242`). The OpenCode config directory must already exist. Herdr installs the server entrypoint at `~/.config/opencode/plugins/herdr-agent-state.js`, the shared TUI plugin at `herdr-tui-session.js`, and a V2 TUI entrypoint at `herdr-opencode/tui.js` in that config directory. -Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries. +Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. An existing V1 registration in `tui.json` also counts as installed and is reused without creating `tui.jsonc`. Uninstall removes the Herdr registration from both TUI config files while keeping the files and other preferences. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries. V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting. diff --git a/src/integration/actions.rs b/src/integration/actions.rs index d51682f8..e57fc0c2 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -556,10 +556,10 @@ pub(crate) fn uninstall_target( result.tui_plugin_path.display() ) }); - if result.updated_tui_config { + for path in result.updated_tui_configs { messages.push(format!( "removed herdr opencode plugin entry from {}", - result.tui_config_path.display() + path.display() )); } messages diff --git a/src/integration/opencode_config.rs b/src/integration/opencode_config.rs index b062365b..888efa13 100644 --- a/src/integration/opencode_config.rs +++ b/src/integration/opencode_config.rs @@ -10,12 +10,17 @@ use super::config_file::{check_config_target, write_config}; const TUI_CONFIG_NAME: &str = "tui.jsonc"; -pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf { - config_dir.join(TUI_CONFIG_NAME) +fn tui_config_paths(config_dir: &Path) -> [PathBuf; 2] { + [ + config_dir.join(TUI_CONFIG_NAME), + config_dir.join("tui.json"), + ] } pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> { - validate_plugin_config(&tui_config_path(config_dir), "plugin")?; + for path in tui_config_paths(config_dir) { + validate_plugin_config(&path, "plugin")?; + } validate_plugin_config(&config_dir.join("cli.json"), "plugins") } @@ -37,7 +42,13 @@ fn validate_plugin_config(config_path: &Path, key: &str) -> io::Result<()> { } pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<PathBuf> { - add_plugin(tui_config_path(config_dir), "plugin", plugin_spec) + for path in tui_config_paths(config_dir) { + if plugin_is_configured(&path, "plugin", plugin_spec) { + return Ok(path); + } + } + // Keep tui.json absent on fresh installs so OpenCode can migrate its settings. + add_plugin(config_dir.join(TUI_CONFIG_NAME), "plugin", plugin_spec) } pub(crate) fn add_cli_plugin( @@ -98,8 +109,21 @@ fn add_plugin(config_path: PathBuf, key: &str, plugin_spec: &str) -> io::Result< Ok(config_path) } -pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> { - remove_plugin(&tui_config_path(config_dir), "plugin", plugin_spec) +pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<Vec<PathBuf>> { + let mut updated = Vec::new(); + let mut errors = Vec::new(); + for path in tui_config_paths(config_dir) { + match remove_plugin(&path, "plugin", plugin_spec) { + Ok(true) => updated.push(path), + Ok(false) => {} + Err(err) => errors.push(err.to_string()), + } + } + if errors.is_empty() { + Ok(updated) + } else { + Err(io::Error::other(errors.join("; "))) + } } pub(crate) fn remove_cli_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> { @@ -143,7 +167,9 @@ fn remove_plugin(config_path: &Path, key: &str, plugin_spec: &str) -> io::Result } pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool { - plugin_is_configured(&tui_config_path(config_dir), "plugin", plugin_spec) + tui_config_paths(config_dir) + .iter() + .any(|path| plugin_is_configured(path, "plugin", plugin_spec)) } pub(crate) fn cli_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool { @@ -323,7 +349,10 @@ mod tests { ]) ); - assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + assert_eq!( + remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(), + vec![config_path.clone()] + ); let removed_content = fs::read_to_string(&config_path).unwrap(); assert!(removed_content.contains("// Keep this comment.")); let removed = parse_config(&config_path); @@ -364,13 +393,33 @@ mod tests { let dir = unique_dir(); let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); - assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + assert_eq!( + remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(), + vec![config_path.clone()] + ); assert!(config_path.is_file()); assert_eq!(parse_config(&config_path), json!({})); fs::remove_dir_all(dir).unwrap(); } + #[test] + fn configured_tui_plugin_accepts_json_registration() { + let dir = unique_dir(); + fs::write( + dir.join("tui.json"), + r#"{"plugin":["./herdr-tui-session.js"]}"#, + ) + .unwrap(); + + let configured = tui_plugin_is_configured(&dir, "./herdr-tui-session.js"); + fs::remove_dir_all(dir).unwrap(); + assert!( + configured, + "OpenCode loads plugin registrations from tui.json" + ); + } + #[test] fn configured_tui_plugin_accepts_option_tuple() { let dir = unique_dir(); @@ -381,7 +430,10 @@ mod tests { .unwrap(); assert!(tui_plugin_is_configured(&dir, "./herdr-tui-state.js")); - assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + assert_eq!( + remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(), + vec![dir.join(TUI_CONFIG_NAME)] + ); fs::remove_dir_all(dir).unwrap(); } diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 6cb90c18..ec605866 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -29,7 +29,7 @@ use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, }; use super::opencode_config::{ - add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin, tui_config_path, + add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin, validate_tui_plugin_config, }; use super::types::{ @@ -461,7 +461,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> { let dir = opencode_dir()?; - check_config_targets(&dir, &["tui.jsonc", "cli.json"])?; + check_config_targets(&dir, &["tui.jsonc", "tui.json", "cli.json"])?; if !dir.is_dir() { return Err(io::Error::other(format!( "opencode config directory not found at {}. install opencode first", @@ -844,8 +844,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { let dir = opencode_dir()?; - check_config_targets(&dir, &["tui.jsonc", "cli.json"])?; - let tui_config_path = tui_config_path(&dir); + check_config_targets(&dir, &["tui.jsonc", "tui.json", "cli.json"])?; let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME); let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME); let mut errors = Vec::new(); @@ -858,10 +857,10 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { errors.push(format!("failed to remove {}: {err}", v2_dir.display())); false }); - let updated_tui_config = + let updated_tui_configs = remove_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap_or_else(|err| { errors.push(err.to_string()); - false + Vec::new() }); let removed_plugin = remove_file_if_exists(&plugin_path).unwrap_or_else(|err| { errors.push(format!("failed to remove {}: {err}", plugin_path.display())); @@ -881,10 +880,9 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { Ok(OpenCodeUninstallResult { plugin_path, tui_plugin_path, - tui_config_path, removed_plugin, removed_tui_plugin, - updated_tui_config, + updated_tui_configs, }) } diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 1a102992..f7e2154e 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -2326,6 +2326,69 @@ fn install_opencode_writes_server_and_tui_plugins() { let _ = fs::remove_dir_all(base); } +#[cfg(unix)] +#[test] +fn opencode_reuses_json_registration_in_symlinked_config_directory() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let dotfiles = base.join("dotfiles"); + let dir = home.join(".config/opencode"); + fs::create_dir_all(home.join(".config")).unwrap(); + fs::create_dir_all(&dotfiles).unwrap(); + std::os::unix::fs::symlink(&dotfiles, &dir).unwrap(); + std::env::set_var("HOME", &home); + let json_path = dir.join("tui.json"); + let original = "{\n // User preferences\n \"theme\":\"system\",\n \"plugin\":[\"other\",[\"./herdr-tui-session.js\",{\"enabled\":true}]]\n}\n"; + fs::write(&json_path, original).unwrap(); + + for _ in 0..2 { + let installed = install_opencode().unwrap(); + assert_eq!(installed.tui_config_path, json_path); + assert!(installed.cli_config_path.is_none()); + assert!(!dir.join("tui.jsonc").exists()); + assert_eq!(fs::read_to_string(&json_path).unwrap(), original); + assert_eq!( + integration_status_at( + crate::api::schema::IntegrationTarget::Opencode, + installed.plugin_path, + OPENCODE_INTEGRATION_VERSION, + ) + .state, + IntegrationStatusKind::Current + ); + } + + // Older installs may have registered the same plugin in both files. + let jsonc_path = dir.join("tui.jsonc"); + fs::write( + &jsonc_path, + r#"{"plugin":["./herdr-tui-session.js","another"]}"#, + ) + .unwrap(); + assert_eq!(install_opencode().unwrap().tui_config_path, jsonc_path); + let result = uninstall_opencode().unwrap(); + assert_eq!( + result.updated_tui_configs, + vec![jsonc_path.clone(), json_path.clone()] + ); + let json = fs::read_to_string(&json_path).unwrap(); + assert!(json.contains("// User preferences")); + assert!(!json.contains("herdr-tui-session.js")); + assert!(json.contains("\"other\"")); + assert!(json.contains("\"system\"")); + assert_eq!( + serde_json::from_str::<Value>(&fs::read_to_string(jsonc_path).unwrap()).unwrap(), + json!({"plugin":["another"]}) + ); + assert!(uninstall_opencode().unwrap().updated_tui_configs.is_empty()); + assert_eq!(fs::read_link(&dir).unwrap(), dotfiles); + assert!(!result.plugin_path.exists()); + assert!(!result.tui_plugin_path.exists()); + std::env::remove_var("HOME"); + fs::remove_dir_all(base).unwrap(); +} + #[test] fn opencode_install_defers_v2_registration_while_migration_pending() { let _lock = integration_env_lock(); @@ -2427,6 +2490,42 @@ fn opencode_hard_link_rejection_precedes_install_and_uninstall_asset_changes() { fs::remove_dir_all(base).unwrap(); } +#[test] +fn opencode_json_config_validation_precedes_asset_changes() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let dir = home.join(".config/opencode"); + fs::create_dir_all(dir.join("plugins")).unwrap(); + std::env::set_var("HOME", &home); + let plugin = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME); + fs::write(&plugin, "previous integration").unwrap(); + let config = dir.join("tui.json"); + fs::write(&config, r#"{"plugin":{}}"#).unwrap(); + assert!(install_opencode() + .unwrap_err() + .to_string() + .contains("plugin list")); + assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration"); + let original = r#"{"plugin":["./herdr-tui-session.js"]}"#; + fs::write(&config, original).unwrap(); + let alias = base.join("linked-config"); + fs::hard_link(&config, &alias).unwrap(); + for error in [ + install_opencode().unwrap_err(), + uninstall_opencode().unwrap_err(), + ] { + assert!(error.to_string().contains("multiple hard links")); + assert!(error.to_string().contains("tui.json")); + } + assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration"); + assert_eq!(fs::read_to_string(&alias).unwrap(), original); + assert!(!dir.join("tui.jsonc").exists()); + assert!(!dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME).exists()); + std::env::remove_var("HOME"); + fs::remove_dir_all(base).unwrap(); +} + #[cfg(windows)] #[test] fn opencode_recovery_copy_blocks_retry_before_parsing_or_asset_changes() { @@ -2539,12 +2638,15 @@ fn uninstall_opencode_removes_plugins_and_managed_tui_config_entry() { assert!(result.removed_plugin); assert!(result.removed_tui_plugin); - assert!(result.updated_tui_config); + assert_eq!( + result.updated_tui_configs, + vec![installed.tui_config_path.clone()] + ); assert!(!result.plugin_path.exists()); assert!(!result.tui_plugin_path.exists()); - assert!(result.tui_config_path.exists()); + assert!(installed.tui_config_path.exists()); let tui_config: Value = - serde_json::from_str(&fs::read_to_string(&result.tui_config_path).unwrap()).unwrap(); + serde_json::from_str(&fs::read_to_string(&installed.tui_config_path).unwrap()).unwrap(); assert_eq!(tui_config, json!({})); assert_eq!(installed.plugin_path, result.plugin_path); @@ -2588,6 +2690,12 @@ fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() { fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET).unwrap(); fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap(); fs::write(opencode_dir.join("tui.jsonc"), "{\"plugin\":").unwrap(); + let json_path = opencode_dir.join("tui.json"); + fs::write( + &json_path, + r#"{"plugin":["./herdr-tui-session.js","other"]}"#, + ) + .unwrap(); std::env::set_var("HOME", &home); let err = uninstall_opencode().unwrap_err().to_string(); @@ -2595,6 +2703,10 @@ fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() { assert!(err.contains("failed to parse OpenCode TUI config")); assert!(!plugin_path.exists()); assert!(!tui_plugin_path.exists()); + assert_eq!( + serde_json::from_str::<Value>(&fs::read_to_string(json_path).unwrap()).unwrap(), + json!({"plugin":["other"]}) + ); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); diff --git a/src/integration/types.rs b/src/integration/types.rs index 928675f6..8e42a5da 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -259,10 +259,9 @@ pub(crate) struct DroidUninstallResult { pub(crate) struct OpenCodeUninstallResult { pub plugin_path: PathBuf, pub tui_plugin_path: PathBuf, - pub tui_config_path: PathBuf, pub removed_plugin: bool, pub removed_tui_plugin: bool, - pub updated_tui_config: bool, + pub updated_tui_configs: Vec<PathBuf>, } #[derive(Debug)] From 5f3763dda88e0fefbce14afab7cc2c7232a5e2a8 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Wed, 16 Sep 2026 23:55:12 +0300 Subject: [PATCH 04/99] fix: cache saved machine connection metadata (#4256) refs #4252 --- .../src/content/docs/connecting-machines.mdx | 2 + src/api/client.rs | 97 ++++++- src/cli.rs | 3 +- src/cli/machine.rs | 34 ++- src/cli/status.rs | 2 +- src/cli/target.rs | 46 +++ src/client/endpoint.rs | 2 + src/client/endpoint/catalog.rs | 6 +- src/client/endpoint/ssh_metadata.rs | 221 +++++++++++++++ src/remote/attach.rs | 185 +++++++++++- src/remote/saved.rs | 46 ++- tests/machine_api.rs | 266 +++++++++++++++++- 12 files changed, 871 insertions(+), 39 deletions(-) create mode 100644 src/client/endpoint/ssh_metadata.rs diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 436c2c2a..0e553552 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -101,6 +101,8 @@ Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines Saved profiles contain only an opaque ID, label, SSH target, explicit remote session, and enabled state. Herdr does not store passwords, private keys, agent tickets, or SSH control sockets in the catalog. Authentication stays with OpenSSH. +Herdr separately remembers each machine's remote OS and resolved executable path so repeated `--machine` commands can skip discovery. Existing profiles learn missing information on first use. This cache is optional: missing, invalid, or unwritable cache files do not prevent commands from working. Commands still check live server compatibility. If a cached executable is missing or no longer supports API forwarding, Herdr rediscovers it during the initial read-only check; it never automatically repeats a command that may already have changed remote state. + The client and server negotiate compatibility rather than requiring identical versions. Saved-machine connections additionally need the server's `surface_interest` and `health_check` capabilities. Older servers without those capabilities show Attention until explicitly updated, even if a standalone attach works. Other missing server methods disable only their corresponding actions. Updating a compatible client does not replace the running remote server or stop its agents. When you need new server-side behavior, update that server explicitly. Normal replacement asks before stopping the server and its pane processes. diff --git a/src/api/client.rs b/src/api/client.rs index 326ebc7d..15d3a884 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<crate::api::RuntimeStatus, ApiClientError> { - let response = self.request(Request { + self.read_status(None) + } + + pub(crate) fn status_with_timeout( + &self, + timeout: Duration, + ) -> Result<crate::api::RuntimeStatus, ApiClientError> { + self.read_status(Some(timeout)) + } + + fn read_status( + &self, + timeout: Option<Duration>, + ) -> Result<crate::api::RuntimeStatus, ApiClientError> { + 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, @@ -162,9 +189,37 @@ fn write_request(stream: &mut LocalStream, request: &Request) -> Result<(), ApiC Ok(()) } -fn read_json_line<T: DeserializeOwned>( - reader: &mut BufReader<LocalStream>, -) -> Result<T, ApiClientError> { +struct DeadlineReader<'a> { + stream: &'a mut LocalStream, + deadline: Instant, +} + +impl Read for DeadlineReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> { + 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<T: DeserializeOwned>(reader: &mut impl BufRead) -> Result<T, ApiClientError> { let mut line = String::new(); let read = reader.read_line(&mut line)?; if read == 0 || line.trim().is_empty() { @@ -199,6 +254,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::<serde_json::Value>(&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/cli.rs b/src/cli.rs index 154e0c99..fead33b6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -782,8 +782,7 @@ pub(super) fn send_request_unchecked(request: &Request) -> std::io::Result<serde } fn ensure_server_protocol_compatible(client: &ApiClient, request_id: &str) -> 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/machine.rs b/src/cli/machine.rs index ab84041e..53404c03 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -164,18 +164,21 @@ fn add(args: &[String]) -> std::io::Result<i32> { return Ok(2); } } - if let Err(error) = crate::remote::prepare_saved_ssh(&target, &session) { - eprintln!("error: {error}; machine was not saved"); - crate::remote::print_saved_ssh_error_hint(&error, &target); - return Ok(1); - } + let metadata = match crate::remote::prepare_saved_ssh(&target, &session) { + Ok(metadata) => metadata, + Err(error) => { + eprintln!("error: {error}; machine was not saved"); + crate::remote::print_saved_ssh_error_hint(&error, &target); + return Ok(1); + } + }; // Setup can wait for human approval. Do not overwrite catalog edits made meanwhile. let mut catalog = load_catalog().map_err(|error| { std::io::Error::other(format!( "remote prepared, but machine was not saved: {error}" )) })?; - let id = match catalog.add_ssh(label, target, session) { + let id = match catalog.add_ssh(label, &target, &session) { Ok(id) => id, Err(error) => { eprintln!("error: {error}"); @@ -187,6 +190,10 @@ fn add(args: &[String]) -> std::io::Result<i32> { "remote prepared, but machine was not saved: {error}" )) })?; + if let Some(metadata) = metadata { + crate::client::endpoint::SshMetadataCache::new(id.as_str(), &target, &session)? + .store(&metadata); + } println!("Saved SSH machine {id}. Remote server is ready."); println!("Open Herdr clients connect automatically."); Ok(0) @@ -232,11 +239,26 @@ fn remove(args: &[String]) -> std::io::Result<i32> { }; let mut catalog = load_catalog()?; let previous_selection = catalog.selected_profile.clone(); + let metadata_cache = catalog + .ssh + .iter() + .find(|profile| profile.id == id) + .map(|profile| { + crate::client::endpoint::SshMetadataCache::new( + id.as_str(), + &profile.target, + &profile.session, + ) + }) + .transpose()?; if !catalog.remove_ssh(&id) { eprintln!("machine profile {id} was not found"); return Ok(1); } store_catalog(&catalog)?; + if let Some(cache) = metadata_cache { + cache.invalidate(); + } if catalog.selected_profile != previous_selection { catalog.store_selection().map_err(std::io::Error::other)?; } diff --git a/src/cli/status.rs b/src/cli/status.rs index d9c0be79..d13be784 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -174,7 +174,7 @@ fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) { } fn read_server_runtime_status() -> std::io::Result<ServerRuntimeStatus> { - match super::target::api_client()?.status() { + match super::target::server_status(&super::target::api_client()?) { Ok(status) => Ok(ServerRuntimeStatus::Running { version: status.version, protocol: status.protocol, diff --git a/src/cli/target.rs b/src/cli/target.rs index 64c072f9..220410f9 100644 --- a/src/cli/target.rs +++ b/src/cli/target.rs @@ -79,6 +79,7 @@ pub(super) fn api_client() -> io::Result<ApiClient> { target.profile.id.as_str(), &target.profile.target, &target.profile.session, + true, ) .map_err(|error| { io::Error::new( @@ -98,6 +99,51 @@ pub(super) fn api_client() -> io::Result<ApiClient> { }) } +pub(super) fn server_status( + client: &ApiClient, +) -> Result<crate::api::RuntimeStatus, crate::api::client::ApiClientError> { + let probe = || { + if is_remote() { + client.status_with_timeout(std::time::Duration::from_secs(15)) + } else { + client.status() + } + }; + let error = match probe() { + Ok(status) => return Ok(status), + Err(error) => error, + }; + // Only this read-only probe may rediscover and retry. Requests that follow + // the probe must never be replayed after an ambiguous SSH failure. + TARGET.with(|target| { + let mut target = target.borrow_mut(); + let Some(target) = target.as_mut() else { + return Err(error); + }; + let Some(bridge) = target.bridge.as_ref() else { + return Err(error); + }; + let Some(failure) = bridge.reported_failure() else { + return Err(error); + }; + if !bridge.used_cached_metadata + || !crate::remote::SavedSshApiBridge::stale_metadata_failure(&failure) + { + return Err(failure.into()); + } + bridge.invalidate_metadata(); + target.bridge.take(); + target.bridge = Some(crate::remote::SavedSshApiBridge::start( + target.profile.id.as_str(), + &target.profile.target, + &target.profile.session, + false, + )?); + Ok(()) + })?; + probe() +} + pub(super) fn remote_error(error: io::Error) -> io::Error { TARGET.with(|target| { let target = target.borrow(); diff --git a/src/client/endpoint.rs b/src/client/endpoint.rs index 77aaae3b..f6ae4109 100644 --- a/src/client/endpoint.rs +++ b/src/client/endpoint.rs @@ -9,6 +9,7 @@ mod control; mod health; mod message_policy; mod registry; +mod ssh_metadata; mod supervisor; mod writer; @@ -17,6 +18,7 @@ pub(crate) use catalog::*; pub(crate) use control::*; pub(crate) use message_policy::*; pub(crate) use registry::*; +pub(crate) use ssh_metadata::{SshMachineMetadata, SshMetadataCache}; pub(crate) use supervisor::*; pub(crate) use writer::NativeEndpointTransport; diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs index 23f26387..e65d1e9e 100644 --- a/src/client/endpoint/catalog.rs +++ b/src/client/endpoint/catalog.rs @@ -331,7 +331,11 @@ fn load_selection_from_path(path: &Path) -> Result<Option<EndpointSelection>, St Ok(Some(selection)) } -fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> { +pub(super) fn store_private_json( + path: &Path, + content: &[u8], + description: &str, +) -> Result<(), String> { if content.len() as u64 > MAX_CATALOG_BYTES { return Err(format!("{description} exceeds the storage limit")); } diff --git a/src/client/endpoint/ssh_metadata.rs b/src/client/endpoint/ssh_metadata.rs new file mode 100644 index 00000000..bc8dffd6 --- /dev/null +++ b/src/client/endpoint/ssh_metadata.rs @@ -0,0 +1,221 @@ +use std::io::{self, Read as _}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::ProfileId; + +const MAX_METADATA_BYTES: u64 = 16 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SshMachineMetadata { + pub(crate) os: String, + pub(crate) executable: String, +} + +impl SshMachineMetadata { + pub(crate) fn is_valid(&self) -> bool { + let path = &self.executable; + if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) { + return false; + } + match self.os.as_str() { + "linux" | "macos" => path.starts_with('/') && !path.ends_with("/mise/shims/herdr"), + "windows" => { + let bytes = path.as_bytes(); + path.starts_with(r"\\") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/')) + } + _ => false, + } + } +} + +#[derive(Serialize, Deserialize)] +struct StoredMetadata { + version: u32, + target: String, + session: String, + metadata: SshMachineMetadata, +} + +pub(crate) struct SshMetadataCache { + path: PathBuf, + target: String, + session: String, +} + +impl SshMetadataCache { + pub(crate) fn new(profile_id: &str, target: &str, session: &str) -> io::Result<Self> { + let id = ProfileId::parse(profile_id) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + Ok(Self { + path: crate::config::state_dir() + .join("client/ssh-metadata") + .join(format!("{id}.json")), + target: target.to_owned(), + session: session.to_owned(), + }) + } + + pub(crate) fn load(&self) -> Option<SshMachineMetadata> { + load_metadata(&self.path, &self.target, &self.session) + } + + pub(crate) fn store(&self, metadata: &SshMachineMetadata) { + if !metadata.is_valid() { + return; + } + let stored = StoredMetadata { + version: 1, + target: self.target.clone(), + session: self.session.clone(), + metadata: metadata.clone(), + }; + let result = serde_json::to_vec(&stored) + .map_err(|error| error.to_string()) + .and_then(|bytes| { + super::catalog::store_private_json(&self.path, &bytes, "SSH metadata") + }); + if let Err(error) = result { + tracing::debug!(%error, "could not cache SSH machine metadata"); + } + } + + pub(crate) fn invalidate(&self) { + if let Err(error) = std::fs::remove_file(&self.path) { + if error.kind() != io::ErrorKind::NotFound { + tracing::debug!(%error, "could not invalidate SSH machine metadata"); + } + } + } +} + +fn load_metadata(path: &Path, target: &str, session: &str) -> Option<SshMachineMetadata> { + let file_type = std::fs::symlink_metadata(path).ok()?.file_type(); + if !file_type.is_file() { + return None; + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .ok()? + .take(MAX_METADATA_BYTES + 1) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() as u64 > MAX_METADATA_BYTES { + return None; + } + let stored: StoredMetadata = serde_json::from_slice(&bytes).ok()?; + (stored.version == 1 + && stored.target == target + && stored.session == session + && stored.metadata.is_valid()) + .then_some(stored.metadata) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_accepts_only_supported_platforms_and_absolute_paths() { + for (os, path, valid) in [ + ("linux", "/home/a b/herdr", true), + ("macos", "/opt/homebrew/bin/herdr", true), + ("windows", r"C:\Program Files\herdr.exe", true), + ("windows", r"\\server\share\herdr.exe", true), + ("windows", "herdr.exe", false), + ("linux", "$HOME/.local/bin/herdr", false), + ("linux", "/home/user/.local/share/mise/shims/herdr", false), + ("linux", "/bin/herdr\nmalformed", false), + ("unknown", "/bin/herdr", false), + ] { + assert_eq!( + SshMachineMetadata { + os: os.into(), + executable: path.into() + } + .is_valid(), + valid, + "{os}: {path}" + ); + } + } + + #[test] + fn metadata_is_disposable_fingerprinted_and_independent_per_profile() { + let root = std::env::temp_dir().join(format!("herdr-ssh-metadata-{}", std::process::id())); + let first = SshMetadataCache { + path: root.join("first.json"), + target: "mac".into(), + session: "fleet".into(), + }; + let second = SshMetadataCache { + path: root.join("second.json"), + target: "mac".into(), + session: "fleet".into(), + }; + let metadata = SshMachineMetadata { + os: "macos".into(), + executable: "/some path/herdr".into(), + }; + assert!(first.load().is_none()); + first.store(&metadata); + second.store(&metadata); + assert_eq!(first.load(), Some(metadata.clone())); + assert!(load_metadata(&first.path, "different-host", "fleet").is_none()); + assert!(load_metadata(&first.path, "mac", "different-session").is_none()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(&first.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let mut stored: serde_json::Value = + serde_json::from_slice(&std::fs::read(&first.path).unwrap()).unwrap(); + stored["future_field"] = true.into(); + std::fs::write(&first.path, serde_json::to_vec(&stored).unwrap()).unwrap(); + assert_eq!(first.load(), Some(metadata.clone())); + stored["version"] = 2.into(); + std::fs::write(&first.path, serde_json::to_vec(&stored).unwrap()).unwrap(); + assert!(first.load().is_none()); + for bytes in [ + b"broken".to_vec(), + vec![b' '; MAX_METADATA_BYTES as usize + 1], + ] { + std::fs::write(&first.path, bytes).unwrap(); + assert!(first.load().is_none()); + } + first.invalidate(); + assert_eq!(second.load(), Some(metadata)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn metadata_does_not_follow_symlinks() { + let root = + std::env::temp_dir().join(format!("herdr-ssh-metadata-link-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let cache = SshMetadataCache { + path: root.join("cache.json"), + target: "mac".into(), + session: "fleet".into(), + }; + let other = root.join("other"); + std::fs::write(&other, "untouched").unwrap(); + std::os::unix::fs::symlink(&other, &cache.path).unwrap(); + assert!(cache.load().is_none()); + cache.store(&SshMachineMetadata { + os: "macos".into(), + executable: "/bin/herdr".into(), + }); + assert_eq!(std::fs::read_to_string(&other).unwrap(), "untouched"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 6a1ee6f9..53023b83 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -86,7 +86,10 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { run_client_process(&local_socket, &reattach_command, remote.keybindings) } -pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result<()> { +pub(crate) fn prepare_saved_ssh( + target: &str, + session_name: &str, +) -> io::Result<Option<crate::client::endpoint::SshMachineMetadata>> { super::validate_remote_target(target) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; crate::session::validate_name(session_name) @@ -135,7 +138,13 @@ pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result< ) .is_none() => { - Ok(()) + Ok(prepared.remote_herdr.machine_metadata().or_else(|| { + discover_remote_api_metadata(&ssh, session_name) + .inspect_err( + |error| tracing::debug!(%error, "could not capture SSH setup metadata"), + ) + .ok() + })) } _ => Err(io::Error::other( "remote server is not ready for saved machines", @@ -318,11 +327,21 @@ impl RemoteExecutable { pub(super) struct RemoteHerdr { install_suffix: String, executable: RemoteExecutable, + resolved_executable: Option<String>, platform: RemotePlatform, bridge_idle_timeout: bool, } impl RemoteHerdr { + pub(super) fn machine_metadata(&self) -> Option<crate::client::endpoint::SshMachineMetadata> { + let executable = self.resolved_executable.clone()?; + let metadata = crate::client::endpoint::SshMachineMetadata { + os: self.platform.os.to_owned(), + executable, + }; + metadata.is_valid().then_some(metadata) + } + fn for_platform(platform: RemotePlatform) -> Self { let (install_suffix, executable) = if platform.is_windows() { ( @@ -337,17 +356,20 @@ impl RemoteHerdr { Self { install_suffix, executable, + resolved_executable: None, platform, bridge_idle_timeout: false, } } - fn with_shell_path(mut self, shell_path: String) -> Self { - self.executable = RemoteExecutable::PosixShellPath(shell_path); + fn with_posix_path(mut self, path: &str) -> Self { + self.executable = RemoteExecutable::PosixShellPath(shell_quote(path)); + self.resolved_executable = Some(path.to_owned()); self } fn with_windows_path(mut self, path: String) -> Self { + self.resolved_executable = Some(path.clone()); self.executable = RemoteExecutable::WindowsPath(path); self } @@ -364,6 +386,10 @@ fn windows_powershell_application_script(path: &str, args: &[&str]) -> String { } fn windows_powershell_streaming_application_command(path: &str, args: &[&str]) -> String { + windows_powershell_script_command(&windows_powershell_streaming_application_script(path, args)) +} + +fn windows_powershell_streaming_application_script(path: &str, args: &[&str]) -> String { let command_line = args .iter() .map(|arg| crate::platform::quote_windows_command_line_arg(arg)) @@ -371,11 +397,11 @@ fn windows_powershell_streaming_application_command(path: &str, args: &[&str]) - .join(" "); // Start-Process -Wait waits for descendants, including a cold-started server. // Retain the handle so Windows PowerShell 5.1 keeps the application's exit code. - windows_powershell_script_command(&format!( + format!( "$process = Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -PassThru -ErrorAction Stop; $null = $process.Handle; $process.WaitForExit(); exit $process.ExitCode", crate::platform::quote_powershell_arg(path), crate::platform::quote_powershell_arg(&command_line), - )) + ) } fn posix_remote_output_command(command: &str) -> String { @@ -1248,11 +1274,29 @@ fn prepare_windows_remote_herdr( }) } -pub(super) fn find_installed_remote_api_herdr( +pub(super) fn discover_remote_api_metadata( ssh: &RemoteSsh, session: &str, -) -> io::Result<RemoteHerdr> { +) -> io::Result<crate::client::endpoint::SshMachineMetadata> { let platform = detect_remote_platform(ssh)?; + if !platform.is_windows() { + let output = + ssh.framed_user_shell_output(&posix_remote_api_discovery_command(&platform, session))?; + if !output.status.success() { + return Err(command_failed("remote binary discovery failed", &output)); + } + let metadata = crate::client::endpoint::SshMachineMetadata { + os: platform.os.to_owned(), + executable: String::from_utf8_lossy(&output.stdout).trim().to_owned(), + }; + if !metadata.is_valid() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid remote Herdr executable path", + )); + } + return Ok(metadata); + } let remote_herdr = RemoteHerdr::for_platform(platform); let candidates = remote_binary_candidates(ssh, &remote_herdr)?; for candidate in candidates { @@ -1264,7 +1308,10 @@ pub(super) fn find_installed_remote_api_herdr( if probe.status.success() && String::from_utf8_lossy(&probe.stdout).trim() == "herdr-api-bridge-v1" { - return Ok(candidate); + return Ok(crate::client::endpoint::SshMachineMetadata { + os: "windows".into(), + executable: candidate.executable.display().to_owned(), + }); } } Err(io::Error::new( @@ -1511,7 +1558,7 @@ fn remote_herdr_from_path(remote_herdr: &RemoteHerdr, path: &str) -> Option<Remo if is_mise_shim_path(path) { return None; } - Some(remote_herdr.clone().with_shell_path(shell_quote(path))) + Some(remote_herdr.clone().with_posix_path(path)) } fn is_mise_shim_path(path: &str) -> bool { @@ -2383,6 +2430,67 @@ fn confirm_remote_install( Ok(()) } +fn posix_remote_api_discovery_command(platform: &RemotePlatform, session: &str) -> String { + let script = format!( + r#"set -f +candidates=$( +command -v herdr +{discovery} +) +IFS=' +' +for candidate in $candidates; do + case "$candidate" in + */mise/shims/herdr) continue ;; + /*) ;; + *) continue ;; + esac + [ -x "$candidate" ] || continue + if capability=$("$candidate" --session {session} remote-api-bridge --check </dev/null 2>/dev/null) && [ "$capability" = herdr-api-bridge-v1 ]; then + printf '%s\n' "$candidate" + exit 0 + fi +done +printf '%s\n' 'remote Herdr does not support machine API forwarding; update Herdr on this machine' >&2 +exit 2"#, + discovery = known_remote_binary_candidate_script(platform), + session = shell_quote(session), + ); + format!( + "/bin/sh -c {}", + shell_quote(&posix_remote_output_command(&script)) + ) +} + +pub(super) const STALE_API_METADATA: &str = "herdr-machine-metadata-stale-v1"; + +pub(super) fn cached_remote_api_command( + metadata: &crate::client::endpoint::SshMachineMetadata, + session: &str, +) -> String { + if metadata.os == "windows" { + let path = crate::platform::quote_powershell_arg(&metadata.executable); + let session_arg = crate::platform::quote_powershell_arg(session); + let probe = format!( + "$capability = & {path} --session {session_arg} remote-api-bridge --check 2>$null; if ($LASTEXITCODE -ne 0 -or $capability -ne 'herdr-api-bridge-v1') {{ [Console]::Error.WriteLine('{STALE_API_METADATA}'); exit 78 }}; " + ); + return windows_powershell_script_command(&format!( + "{probe}{}", + windows_powershell_streaming_application_script( + &metadata.executable, + &["--session", session, "remote-api-bridge"] + ), + )); + } + let path = shell_quote(&metadata.executable); + let session = shell_quote(session); + let script = format!( + "if capability=$({path} --session {session} remote-api-bridge --check </dev/null 2>/dev/null) && [ \"$capability\" = herdr-api-bridge-v1 ]; then\n{}\nelse\n printf '%s\\n' '{STALE_API_METADATA}' >&2\n exit 78\nfi", + posix_remote_output_command(&format!("exec {path} --session {session} remote-api-bridge")), + ); + format!("/bin/sh -c {}", shell_quote(&script)) +} + pub(super) fn remote_api_bridge_command( remote_herdr: &RemoteHerdr, session_name: &str, @@ -4069,6 +4177,63 @@ mod tests { assert_eq!(status.and_then(|status| status.code()), Some(23)); } + #[test] + fn machine_metadata_keeps_raw_resolved_paths_not_shell_expressions() { + let remote = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + assert!(remote.machine_metadata().is_none()); + let path = "/home/user's files/$literal/herdr"; + let resolved = remote.with_posix_path(path); + assert_eq!(resolved.machine_metadata().unwrap().executable, path); + assert_eq!(resolved.executable.display(), shell_quote(path)); + let remote = RemoteHerdr::for_platform(RemotePlatform { + os: "windows", + arch: "x86_64", + }); + assert!(remote.machine_metadata().is_none()); + let path = r"C:\Users\A B\herdr.exe"; + assert_eq!( + remote + .with_windows_path(path.into()) + .machine_metadata() + .unwrap() + .executable, + path + ); + } + + #[test] + fn cached_windows_api_command_checks_before_starting_the_stream() { + let path = r"C:\Users\A'B\herdr.exe"; + let command = cached_remote_api_command( + &crate::client::endpoint::SshMachineMetadata { + os: "windows".into(), + executable: path.into(), + }, + "fleet", + ); + let encoded = command.split_whitespace().last().unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let words = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::<Vec<_>>(); + let script = String::from_utf16(&words).unwrap(); + assert!(script.contains(&crate::platform::quote_powershell_arg(path))); + assert!(script.contains(STALE_API_METADATA)); + assert!( + script.find("remote-api-bridge --check").unwrap() + < script.find("Start-Process").unwrap() + ); + assert!(script.contains("$LASTEXITCODE -ne 0")); + assert!(script.contains("-NoNewWindow -PassThru")); + assert!(script.contains("--session fleet remote-api-bridge")); + } + #[test] fn windows_remote_commands_use_one_encoded_powershell_grammar() { let executable = RemoteExecutable::WindowsPath("herdr.exe".to_string()); diff --git a/src/remote/saved.rs b/src/remote/saved.rs index 561ee1a0..4d8bd74b 100644 --- a/src/remote/saved.rs +++ b/src/remote/saved.rs @@ -19,6 +19,7 @@ pub(crate) fn connect_saved_ssh( ) -> io::Result<SavedSshStream> { let ssh = validated_saved_ssh(profile_id, target, session)?; let remote_herdr = find_installed_remote_herdr(&ssh)?; + let metadata = remote_herdr.machine_metadata(); let path = saved_bridge_path(profile_id); let bridge = SshStdioBridge::start( target.to_owned(), @@ -29,6 +30,10 @@ pub(crate) fn connect_saved_ssh( true, )?; let stream = crate::ipc::connect_local_stream(&path)?; + if let Some(metadata) = metadata { + crate::client::endpoint::SshMetadataCache::new(profile_id, target, session)? + .store(&metadata); + } Ok(SavedSshStream { stream, bridge: SavedSshBridge { _bridge: bridge }, @@ -38,13 +43,31 @@ pub(crate) fn connect_saved_ssh( pub(crate) struct SavedSshApiBridge { path: PathBuf, bridge: SshStdioBridge, + metadata_cache: crate::client::endpoint::SshMetadataCache, + pub(crate) used_cached_metadata: bool, } impl SavedSshApiBridge { - pub(crate) fn start(profile_id: &str, target: &str, session: &str) -> io::Result<Self> { + pub(crate) fn start( + profile_id: &str, + target: &str, + session: &str, + use_cached_metadata: bool, + ) -> io::Result<Self> { let ssh = validated_saved_ssh(profile_id, target, session)?; - let remote_herdr = super::attach::find_installed_remote_api_herdr(&ssh, session)?; - let command = super::attach::remote_api_bridge_command(&remote_herdr, session, false); + let metadata_cache = + crate::client::endpoint::SshMetadataCache::new(profile_id, target, session)?; + let cached = use_cached_metadata.then(|| metadata_cache.load()).flatten(); + let used_cached_metadata = cached.is_some(); + let metadata = match cached { + Some(metadata) => metadata, + None => { + let metadata = super::attach::discover_remote_api_metadata(&ssh, session)?; + metadata_cache.store(&metadata); + metadata + } + }; + let command = super::attach::cached_remote_api_command(&metadata, session); let path = crate::platform::remote_bridge_endpoint_path( &format!("herdr-api-ssh-{}-{profile_id}.sock", std::process::id()), &format!( @@ -60,7 +83,12 @@ impl SavedSshApiBridge { ssh.options(), true, )?; - Ok(Self { path, bridge }) + Ok(Self { + path, + bridge, + metadata_cache, + used_cached_metadata, + }) } pub(crate) fn socket_path(&self) -> &std::path::Path { @@ -70,6 +98,16 @@ impl SavedSshApiBridge { pub(crate) fn reported_failure(&self) -> Option<io::Error> { self.bridge.reported_failure() } + + pub(crate) fn invalidate_metadata(&self) { + self.metadata_cache.invalidate(); + } + + pub(crate) fn stale_metadata_failure(error: &io::Error) -> bool { + error + .to_string() + .contains(super::attach::STALE_API_METADATA) + } } pub(crate) fn saved_ssh_bootstrap_command(target: &str, session: &str) -> String { diff --git a/tests/machine_api.rs b/tests/machine_api.rs index 895e440a..a2a9a131 100644 --- a/tests/machine_api.rs +++ b/tests/machine_api.rs @@ -13,24 +13,21 @@ use serde_json::{json, Value}; const PROFILE_ID: &str = "0123456789abcdef0123456789abcdef"; const SSH: &str = r#"#!/bin/sh +printf 'ssh\n' >> "$TEST_ROOT/ssh-calls" for arg do last=$arg printf '%s\n' "$arg" >> "$TEST_ROOT/ssh-args" done case "$last" in - *'command -v herdr') printf 'login banner\nherdr-remote-output-ready:1\n%s\n' "$TEST_REMOTE_HERDR" ;; - *'remote-api-bridge --check') - if [ "$TEST_MODE" = old ]; then exit 2; fi - exec /bin/sh -c "$last" ;; - *'remote-api-bridge') + '/bin/sh -c '*) if [ "$TEST_MODE" = offline ]; then echo 'test remote connection failed' >&2; exit 255; fi - exec /bin/sh -c "$last" ;; + printf 'login banner\n' + PATH="$TEST_ROOT/remote bin:/usr/bin:/bin" exec /bin/sh -c "$last" ;; '/bin/sh -s') script=$(cat) printf 'login banner\nherdr-remote-output-ready:1\n' case "$script" in *'uname -s'*) uname -s; uname -m ;; - *'version='*) printf '%s\n' "$TEST_REMOTE_HERDR" ;; *) echo "unexpected discovery: $script" >&2; exit 2 ;; esac ;; *) echo "unexpected command: $last" >&2; exit 2 ;; @@ -39,6 +36,7 @@ esac struct Harness { root: PathBuf, + state: PathBuf, remote: UnixListener, local: UnixListener, protocol: u64, @@ -65,6 +63,24 @@ impl Harness { fs::write(root.join("bin/ssh"), SSH).unwrap(); fs::set_permissions(root.join("bin/ssh"), fs::Permissions::from_mode(0o700)).unwrap(); std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_herdr"), root.join("remote herdr")).unwrap(); + fs::create_dir_all(root.join("remote bin")).unwrap(); + let remote_wrapper = root.join("remote bin/herdr"); + fs::write( + &remote_wrapper, + r#"#!/bin/sh +if [ "$TEST_MODE" = old ]; then printf 'herdr-api-bridge-v1\n'; exit 2; fi +case "$*" in + *'--check') + if IFS= read -r request; then + echo 'capability check consumed API stdin' >&2 + exit 2 + fi ;; +esac +exec "$TEST_REMOTE_HERDR" "$@" +"#, + ) + .unwrap(); + fs::set_permissions(&remote_wrapper, fs::Permissions::from_mode(0o700)).unwrap(); fs::write(state.join("endpoints.json"), serde_json::to_vec(&json!({ "version": 1, "ssh": [{"id": PROFILE_ID, "label": "mac", "target": "fake-mac", "session": "fleet", "enabled": true}] @@ -80,6 +96,7 @@ impl Harness { let status: Value = serde_json::from_slice(&status.stdout).unwrap(); Self { root, + state, remote, local, protocol: status["protocol"].as_u64().unwrap(), @@ -138,6 +155,9 @@ impl Harness { .unwrap(); let request: Value = serde_json::from_str(&line).unwrap(); let ping = request["method"] == "ping"; + if !ping && result.is_null() { + return request; + } let response = if ping { json!({"id": request["id"], "result": {"type": "pong", "version": "test", "protocol": protocol}}) } else { @@ -154,6 +174,23 @@ impl Harness { }) } + fn warm_metadata(&self) { + let server = self.serve(json!({}), 0); + success( + self.command(&["--machine", "mac", "status", "server", "--json"]) + .output() + .unwrap(), + ); + assert_eq!(server.join().unwrap()["method"], "ping"); + } + + fn ssh_calls(&self) -> usize { + fs::read_to_string(self.root.join("ssh-calls")) + .unwrap_or_default() + .lines() + .count() + } + fn assert_local_untouched(&self) { assert_eq!( self.local.accept().unwrap_err().kind(), @@ -205,7 +242,8 @@ fn machine_api_routes_structured_payload_and_remote_errors_without_local_fallbac let ssh_args = fs::read_to_string(harness.root.join("ssh-args")).unwrap(); assert!(ssh_args.contains("StrictHostKeyChecking=yes")); assert!(ssh_args.contains("BatchMode=yes")); - assert!(ssh_args.contains("--session fleet remote-api-bridge")); + assert!(ssh_args.contains("remote-api-bridge")); + assert!(ssh_args.contains("fleet")); assert!(!ssh_args.contains("should-not-exist")); harness.assert_local_untouched(); } @@ -226,6 +264,209 @@ fn machine_api_profile_id_routes_large_list_responses() { ); assert_eq!(response["result"]["test_data"], data); assert_eq!(server.join().unwrap()["method"], "agent.list"); + assert_eq!( + fs::read_to_string(harness.root.join("ssh-calls")) + .unwrap() + .lines() + .count(), + 4, + "cold command: platform, discovery, protocol ping, request" + ); + harness.assert_local_untouched(); +} + +#[test] +fn machine_api_bootstrap_falls_back_from_an_old_path_binary() { + let harness = Harness::new(); + fs::create_dir_all(harness.root.join(".local/bin")).unwrap(); + std::os::unix::fs::symlink( + env!("CARGO_BIN_EXE_herdr"), + harness.root.join(".local/bin/herdr"), + ) + .unwrap(); + let server = harness.serve( + json!({"result":{"type":"agent_list","agents":[]}}), + harness.protocol, + ); + success( + harness + .command(&["--machine", "mac", "agent", "list"]) + .env("TEST_MODE", "old") + .output() + .unwrap(), + ); + assert_eq!(server.join().unwrap()["method"], "agent.list"); + harness.assert_local_untouched(); +} + +#[test] +fn machine_api_reuses_discovery_across_commands_without_rewriting_profiles() { + let harness = Harness::new(); + let catalog_before = fs::read(harness.state.join("endpoints.json")).unwrap(); + for expected_calls in [None, Some(2)] { + let before = harness.ssh_calls(); + let server = harness.serve( + json!({"result":{"type":"agent_list","agents":[]}}), + harness.protocol, + ); + success( + harness + .command(&["--machine", "mac", "agent", "list"]) + .output() + .unwrap(), + ); + assert_eq!(server.join().unwrap()["method"], "agent.list"); + if let Some(expected) = expected_calls { + assert_eq!( + harness.ssh_calls() - before, + expected, + "warm commands must skip discovery" + ); + } + } + let before = harness.ssh_calls(); + let server = harness.serve(json!({}), 0); + success( + harness + .command(&["--machine", "mac", "status", "server", "--json"]) + .output() + .unwrap(), + ); + assert_eq!(server.join().unwrap()["method"], "ping"); + assert_eq!( + harness.ssh_calls() - before, + 1, + "warm status needs only one SSH" + ); + assert_eq!( + fs::read(harness.state.join("endpoints.json")).unwrap(), + catalog_before + ); + harness.assert_local_untouched(); +} + +#[test] +fn machine_api_recovers_a_stale_path_before_sending_a_mutation() { + let harness = Harness::new(); + harness.warm_metadata(); + fs::remove_file(harness.root.join("remote bin/herdr")).unwrap(); + fs::create_dir_all(harness.root.join(".local/bin")).unwrap(); + std::os::unix::fs::symlink( + env!("CARGO_BIN_EXE_herdr"), + harness.root.join(".local/bin/herdr"), + ) + .unwrap(); + let before = harness.ssh_calls(); + let server = harness.serve(json!({"result":{"type":"ok"}}), harness.protocol); + success( + harness + .command(&["--machine", "mac", "pane", "close", "w4:p1"]) + .output() + .unwrap(), + ); + assert_eq!(server.join().unwrap()["method"], "pane.close"); + assert_eq!( + harness.ssh_calls() - before, + 5, + "one failed ping followed by fresh discovery and one command" + ); + let before = harness.ssh_calls(); + harness.warm_metadata(); + assert_eq!( + harness.ssh_calls() - before, + 1, + "recovered path must be saved" + ); + harness.assert_local_untouched(); +} + +#[test] +fn machine_api_never_replays_a_mutation_when_its_response_is_lost() { + let harness = Harness::new(); + harness.warm_metadata(); + let before = harness.ssh_calls(); + let server = harness.serve(Value::Null, harness.protocol); + let output = harness + .command(&["--machine", "mac", "pane", "close", "w4:p1"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_eq!(server.join().unwrap()["method"], "pane.close"); + assert_eq!( + harness.ssh_calls() - before, + 2, + "mutation must not trigger rediscovery or replay" + ); + harness.assert_local_untouched(); +} + +#[test] +fn machine_api_transient_connection_failure_keeps_working_metadata() { + let harness = Harness::new(); + harness.warm_metadata(); + let before = harness.ssh_calls(); + let output = harness + .command(&["--machine", "mac", "agent", "list"]) + .env("TEST_MODE", "offline") + .output() + .unwrap(); + assert!(!output.status.success()); + assert_eq!( + harness.ssh_calls() - before, + 1, + "network failures must not retry" + ); + let before = harness.ssh_calls(); + harness.warm_metadata(); + assert_eq!( + harness.ssh_calls() - before, + 1, + "transient failure must not discard metadata" + ); +} + +#[test] +fn machine_remove_deletes_only_that_profiles_metadata() { + let harness = Harness::new(); + harness.warm_metadata(); + let directory = harness.state.join("ssh-metadata"); + let other = directory.join("fedcba9876543210fedcba9876543210.json"); + fs::write(&other, "other machine metadata").unwrap(); + let before = harness.ssh_calls(); + let output = harness + .command(&["machine", "remove", PROFILE_ID]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!directory.join(format!("{PROFILE_ID}.json")).exists()); + assert_eq!(fs::read_to_string(other).unwrap(), "other machine metadata"); + assert_eq!(harness.ssh_calls(), before); + harness.assert_local_untouched(); +} + +#[test] +fn machine_api_bad_or_unwritable_metadata_does_not_block_commands() { + let harness = Harness::new(); + harness.warm_metadata(); + let path = harness + .state + .join("ssh-metadata") + .join(format!("{PROFILE_ID}.json")); + fs::write(&path, "broken metadata").unwrap(); + let before = harness.ssh_calls(); + harness.warm_metadata(); + assert_eq!(harness.ssh_calls() - before, 3); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + for _ in 0..2 { + let before = harness.ssh_calls(); + harness.warm_metadata(); + assert_eq!(harness.ssh_calls() - before, 3); + } harness.assert_local_untouched(); } @@ -303,6 +544,14 @@ fn machine_api_status_reports_remote_identity_not_local_installation_state() { assert_eq!(status["socket"], format!("machine:{PROFILE_ID}/fleet")); assert!(status["server_binary_stale"].is_null()); assert_eq!(server.join().unwrap()["method"], "ping"); + assert_eq!( + fs::read_to_string(harness.root.join("ssh-calls")) + .unwrap() + .lines() + .count(), + 3, + "cold status: platform, discovery, status" + ); harness.assert_local_untouched(); } @@ -389,6 +638,7 @@ fn machine_api_server_stop_is_sent_only_to_the_selected_machine() { #[test] fn machine_api_protocol_mismatch_never_sends_the_mutation() { let harness = Harness::new(); + harness.warm_metadata(); let server = harness.serve(json!({}), 0); let output = harness .command(&["--machine", "mac", "pane", "close", "w4:p1"]) From 20c15dd7c8e516753a09e74d400b0637f5fd5682 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 17 Sep 2026 00:07:58 +0200 Subject: [PATCH 05/99] fix(windows): preserve VT input during mouse refresh (#4257) refs #4251 --- src/client/input.rs | 2 +- src/client/input/windows_vti.rs | 10 ---------- src/client/terminal_setup.rs | 27 +++++++++++++++++++++------ src/platform/windows.rs | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index c86f0878..e77d51fa 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -330,7 +330,7 @@ fn windows_stdin_reader_loop( windows_crossterm_reader_loop(event_tx, should_quit); } else { match windows_vti::console_input_handle() { - Ok(handle) if windows_vti::virtual_terminal_input_enabled(handle) => { + Ok(handle) if crate::platform::windows_virtual_terminal_input_active() => { windows_vti::raw_console_reader_loop(handle, event_tx, should_quit); } _ => windows_crossterm_reader_loop(event_tx, should_quit), diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 95e01d4b..e9cfa4e6 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -96,16 +96,6 @@ pub(super) fn console_input_handle() -> std::io::Result<windows_sys::Win32::Foun } } -#[cfg(windows)] -pub(super) fn virtual_terminal_input_enabled( - handle: windows_sys::Win32::Foundation::HANDLE, -) -> bool { - use windows_sys::Win32::System::Console::{GetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT}; - - let mut mode = 0; - (unsafe { GetConsoleMode(handle, &mut mode) } != 0) && mode & ENABLE_VIRTUAL_TERMINAL_INPUT != 0 -} - #[cfg(windows)] enum WindowsInputItems { Items(Vec<PlatformInputItem>), diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 44f6fbaa..e42c5ec8 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -280,9 +280,15 @@ fn set_windows_native_mouse_capture<W: io::Write>( Ok(()) } +#[cfg(windows)] +fn windows_uses_vt_mouse_reporting() -> bool { + windows_vti_input_backend_enabled() + && (is_ssh_session() || crate::platform::windows_virtual_terminal_input_active()) +} + pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { #[cfg(windows)] - if is_ssh_session() && windows_vti_input_backend_enabled() { + if windows_uses_vt_mouse_reporting() { crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; return crate::terminal_modes::set_windows_mouse_reporting( &mut io::stdout(), @@ -295,11 +301,7 @@ pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<( if enabled { execute!(io::stdout(), EnableMouseCapture) } else { - match execute!(io::stdout(), DisableMouseCapture) { - Ok(()) => Ok(()), - Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), - Err(err) => Err(err), - } + disable_windows_native_mouse_capture() } }); #[cfg(not(windows))] @@ -320,6 +322,15 @@ pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<( } } +#[cfg(windows)] +fn disable_windows_native_mouse_capture() -> io::Result<()> { + match execute!(io::stdout(), DisableMouseCapture) { + Ok(()) => Ok(()), + Err(err) if err.to_string() == "Initial console modes not set" => Ok(()), + Err(err) => Err(err), + } +} + fn restore_terminal_state_once( restore_claimed: &AtomicBool, reset_keyboard_enhancements: bool, @@ -368,6 +379,10 @@ fn restore_terminal_state( if let Some(mode) = restore_windows_input_mode { restore_windows_input_mode_value(mode); } + #[cfg(windows)] + if !is_ssh_session() { + let _ = disable_windows_native_mouse_capture(); + } let restore_result = ratatui::try_restore(); let postlude_result = diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 9742b52c..9e39a715 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -16,6 +16,20 @@ use std::{ mod clipboard_image; mod config_backup; +pub(crate) fn windows_virtual_terminal_input_active() -> bool { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::{ + GetConsoleMode, GetStdHandle, ENABLE_VIRTUAL_TERMINAL_INPUT, STD_INPUT_HANDLE, + }; + + let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return false; + } + let mut mode = 0; + (unsafe { GetConsoleMode(handle, &mut mode) } != 0) && mode & ENABLE_VIRTUAL_TERMINAL_INPUT != 0 +} + pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::ChildExitReason { // STATUS_CONTROL_C_EXIT is reported without a Unix signal by portable-pty. if status.exit_code() == 0xC000013A { From 063e937ee98924893eb487b070bf1e583377084c Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 17 Sep 2026 02:11:03 +0200 Subject: [PATCH 06/99] fix(windows): restore terminal after setup errors (#4261) refs #4251 --- src/client/terminal_setup.rs | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index e42c5ec8..5ea7001f 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -41,6 +41,15 @@ pub(super) fn setup_terminal_with_capabilities( mouse_capture: bool, ) -> io::Result<TerminalGuard> { ratatui::init(); + let mut terminal_guard = TerminalGuard { + reset_keyboard_enhancements: false, + reset_modify_other_keys: false, + reset_host_color_scheme_reports: false, + restore_claimed: Arc::new(AtomicBool::new(false)), + restored: false, + #[cfg(windows)] + restore_windows_input_mode: None, + }; crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; let host_color_scheme_reports = should_enable_host_color_scheme_reports(enable_client_protocols); @@ -54,13 +63,19 @@ pub(super) fn setup_terminal_with_capabilities( } else { WindowsVirtualTerminalInputSetup::default() }; + #[cfg(windows)] + { + terminal_guard.restore_windows_input_mode = windows_virtual_terminal_input.restore_mode; + } if enable_client_protocols { set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; if host_color_scheme_reports { + terminal_guard.reset_host_color_scheme_reports = true; write_host_color_scheme_report_mode(&mut io::stdout(), true)?; } + terminal_guard.reset_keyboard_enhancements = true; push_keyboard_enhancement_flags()?; } else { if should_query_host_terminal_theme() { @@ -73,6 +88,7 @@ pub(super) fn setup_terminal_with_capabilities( #[cfg(windows)] if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); + terminal_guard.restore_windows_input_mode = windows_virtual_terminal_input.restore_mode; } #[cfg(windows)] @@ -81,33 +97,21 @@ pub(super) fn setup_terminal_with_capabilities( && windows_virtual_terminal_input.active && windows_win32_input_mode_enabled() { - if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) { - if let Some(mode) = windows_virtual_terminal_input.restore_mode { - restore_windows_input_mode_value(mode); - } - return Err(err); - } + enable_windows_win32_input_mode(&mut io::stdout())?; } let modify_other_keys_mode = enable_client_protocols .then(crate::input::host_modify_other_keys_mode) .flatten(); if let Some(mode) = modify_other_keys_mode { + terminal_guard.reset_modify_other_keys = true; io::stdout().write_all(mode.set_sequence())?; io::stdout().flush()?; } execute!(io::stdout(), DisableLineWrap)?; - Ok(TerminalGuard { - reset_keyboard_enhancements: enable_client_protocols, - reset_modify_other_keys: modify_other_keys_mode.is_some(), - reset_host_color_scheme_reports: host_color_scheme_reports, - restore_claimed: Arc::new(AtomicBool::new(false)), - restored: false, - #[cfg(windows)] - restore_windows_input_mode: windows_virtual_terminal_input.restore_mode, - }) + Ok(terminal_guard) } pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { From e7e3dfa60e359404def46503dd105c165d02a561 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 17 Sep 2026 03:14:57 +0200 Subject: [PATCH 07/99] fix(windows): recover VT input without changing readers (#4269) refs #4251 --- src/client/input.rs | 2 +- src/client/input/windows_vti.rs | 11 +- src/client/mod.rs | 28 +++-- src/client/terminal_setup.rs | 190 +++++++++++++++++++++++++++----- 4 files changed, 185 insertions(+), 46 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index e77d51fa..848d0156 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -330,7 +330,7 @@ fn windows_stdin_reader_loop( windows_crossterm_reader_loop(event_tx, should_quit); } else { match windows_vti::console_input_handle() { - Ok(handle) if crate::platform::windows_virtual_terminal_input_active() => { + Ok(handle) => { windows_vti::raw_console_reader_loop(handle, event_tx, should_quit); } _ => windows_crossterm_reader_loop(event_tx, should_quit), diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index e9cfa4e6..48fe8220 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -86,14 +86,17 @@ fn push_platform_input_events( #[cfg(windows)] pub(super) fn console_input_handle() -> std::io::Result<windows_sys::Win32::Foundation::HANDLE> { use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::System::Console::{GetStdHandle, STD_INPUT_HANDLE}; + use windows_sys::Win32::System::Console::{GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE}; let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; if handle.is_null() || handle == INVALID_HANDLE_VALUE { - Err(std::io::Error::last_os_error()) - } else { - Ok(handle) + return Err(std::io::Error::last_os_error()); } + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(handle) } #[cfg(windows)] diff --git a/src/client/mod.rs b/src/client/mod.rs index cb465096..1cef87a7 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -71,7 +71,7 @@ use terminal_geometry::{ use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size}; use terminal_setup::{ effective_mouse_capture, effective_sgr_pixel_mouse, set_mouse_capture, - setup_direct_attach_terminal, setup_terminal, should_draw_host_cursor, + setup_direct_attach_terminal, setup_terminal, should_draw_host_cursor, TerminalGuard, }; fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) { @@ -80,9 +80,7 @@ fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) { } } #[cfg(windows)] -use terminal_setup::{ - enable_windows_virtual_terminal_input, is_ssh_session, windows_vti_input_backend_enabled, -}; +use terminal_setup::{is_ssh_session, windows_vti_input_backend_enabled}; #[cfg(test)] use terminal_setup::{ should_enable_host_color_scheme_reports, windows_virtual_terminal_input_mode, @@ -327,6 +325,7 @@ fn run_client_with_mode( should_quit, loop_config, attach_escape, + &terminal_guard, ) .await }); @@ -377,6 +376,7 @@ async fn run_client_loop( should_quit: Arc<AtomicBool>, config: ClientLoopConfig, attach_escape: Option<AttachEscapeState>, + _terminal_guard: &TerminalGuard, ) -> Result<(), ClientError> { #[cfg(windows)] let _ = config.mouse_scroll_lines; @@ -1827,17 +1827,21 @@ async fn run_client_loop( ); let mouse_mode_changed = enabled != state.mouse_capture_active || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { + _terminal_guard + .recover_windows_virtual_terminal_input() + .map_err(ClientError::ConnectionFailed)?; + } if mouse_mode_changed { - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); - } set_mouse_capture(enabled, next_sgr_pixels) .map_err(ClientError::ConnectionFailed)?; - #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { - let _ = enable_windows_virtual_terminal_input(); - } + } + #[cfg(windows)] + if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { + _terminal_guard + .recover_windows_virtual_terminal_input() + .map_err(ClientError::ConnectionFailed)?; } state.mouse_capture_active = enabled; host_mouse_capture_active.store(enabled, Ordering::Release); diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 5ea7001f..1e072a4f 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -3,6 +3,8 @@ use std::io::{self, Write as _}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +#[cfg(windows)] +use std::sync::{Mutex, MutexGuard}; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -48,25 +50,23 @@ pub(super) fn setup_terminal_with_capabilities( restore_claimed: Arc::new(AtomicBool::new(false)), restored: false, #[cfg(windows)] - restore_windows_input_mode: None, + restore_windows_input_mode: Arc::new(WindowsInputModeRestore::default()), }; crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; let host_color_scheme_reports = should_enable_host_color_scheme_reports(enable_client_protocols); - #[cfg(windows)] let windows_ssh_session = is_ssh_session(); #[cfg(windows)] let mut windows_virtual_terminal_input = if windows_vti_input_backend_enabled() && windows_ssh_session { - enable_windows_virtual_terminal_input() + enable_windows_virtual_terminal_input( + &terminal_guard.restore_claimed, + &terminal_guard.restore_windows_input_mode, + ) } else { WindowsVirtualTerminalInputSetup::default() }; - #[cfg(windows)] - { - terminal_guard.restore_windows_input_mode = windows_virtual_terminal_input.restore_mode; - } if enable_client_protocols { set_mouse_capture(mouse_capture, false)?; @@ -87,8 +87,10 @@ pub(super) fn setup_terminal_with_capabilities( #[cfg(windows)] if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { - windows_virtual_terminal_input = enable_windows_virtual_terminal_input(); - terminal_guard.restore_windows_input_mode = windows_virtual_terminal_input.restore_mode; + windows_virtual_terminal_input = enable_windows_virtual_terminal_input( + &terminal_guard.restore_claimed, + &terminal_guard.restore_windows_input_mode, + ); } #[cfg(windows)] @@ -126,7 +128,7 @@ pub(super) struct TerminalGuard { restore_claimed: Arc<AtomicBool>, restored: bool, #[cfg(windows)] - restore_windows_input_mode: Option<u32>, + restore_windows_input_mode: Arc<WindowsInputModeRestore>, } pub(super) fn write_host_color_scheme_report_mode( @@ -171,10 +173,59 @@ pub(super) fn should_draw_host_cursor(mode: crate::config::HostCursorModeConfig) pub(super) struct WindowsVirtualTerminalInputSetup { active: bool, restore_mode: Option<u32>, + warning: Option<&'static str>, +} + +#[cfg(windows)] +#[derive(Default)] +struct WindowsInputModeRestore { + mode: Mutex<Option<u32>>, +} + +#[cfg(windows)] +impl WindowsInputModeRestore { + fn lock(&self) -> MutexGuard<'_, Option<u32>> { + match self.mode.lock() { + Ok(mode) => mode, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn activate( + &self, + restore_claimed: &AtomicBool, + operation: impl FnOnce() -> WindowsVirtualTerminalInputSetup, + ) -> WindowsVirtualTerminalInputSetup { + let mut restore_mode = self.lock(); + if restore_claimed.load(Ordering::Acquire) { + return WindowsVirtualTerminalInputSetup::default(); + } + let setup = operation(); + if restore_mode.is_none() { + *restore_mode = setup.restore_mode; + } + setup + } + + fn take(&self) -> Option<u32> { + self.lock().take() + } } #[cfg(windows)] -pub(super) fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup { +fn enable_windows_virtual_terminal_input( + restore_claimed: &AtomicBool, + restore_mode: &WindowsInputModeRestore, +) -> WindowsVirtualTerminalInputSetup { + let setup = restore_mode.activate(restore_claimed, enable_windows_virtual_terminal_input_inner); + if let Some(warning) = setup.warning { + tracing::warn!("{warning}"); + } + setup +} + +#[cfg(windows)] +fn enable_windows_virtual_terminal_input_inner() -> WindowsVirtualTerminalInputSetup { use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::Console::{ GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT, @@ -183,44 +234,65 @@ pub(super) fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalI let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; if handle.is_null() || handle == INVALID_HANDLE_VALUE { - tracing::warn!("failed to get Windows console input handle for VT input"); - return WindowsVirtualTerminalInputSetup::default(); + return WindowsVirtualTerminalInputSetup { + warning: Some("failed to get Windows console input handle for VT input"), + ..WindowsVirtualTerminalInputSetup::default() + }; } let mut mode = 0; if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { - tracing::warn!("failed to read Windows console input mode for VT input"); - return WindowsVirtualTerminalInputSetup::default(); + return WindowsVirtualTerminalInputSetup { + warning: Some("failed to read Windows console input mode for VT input"), + ..WindowsVirtualTerminalInputSetup::default() + }; } let desired = windows_virtual_terminal_input_mode(mode); if desired == mode { return WindowsVirtualTerminalInputSetup { active: true, - restore_mode: None, + ..WindowsVirtualTerminalInputSetup::default() }; } if unsafe { SetConsoleMode(handle, desired) } == 0 { - tracing::warn!("failed to enable Windows virtual terminal input"); - return WindowsVirtualTerminalInputSetup::default(); + return WindowsVirtualTerminalInputSetup { + warning: Some("failed to enable Windows virtual terminal input"), + ..WindowsVirtualTerminalInputSetup::default() + }; } let mut applied = 0; if unsafe { GetConsoleMode(handle, &mut applied) } == 0 { - tracing::warn!("failed to verify Windows virtual terminal input mode"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); + let rollback_failed = unsafe { SetConsoleMode(handle, mode) } == 0; + return WindowsVirtualTerminalInputSetup { + restore_mode: rollback_failed.then_some(mode), + warning: Some(if rollback_failed { + "failed to verify or restore Windows virtual terminal input mode" + } else { + "failed to verify Windows virtual terminal input mode" + }), + ..WindowsVirtualTerminalInputSetup::default() + }; } if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 { - tracing::warn!("Windows virtual terminal input bit did not stick"); - let _ = unsafe { SetConsoleMode(handle, mode) }; - return WindowsVirtualTerminalInputSetup::default(); + let rollback_failed = unsafe { SetConsoleMode(handle, mode) } == 0; + return WindowsVirtualTerminalInputSetup { + restore_mode: rollback_failed.then_some(mode), + warning: Some(if rollback_failed { + "Windows virtual terminal input bit did not stick and the prior mode could not be restored" + } else { + "Windows virtual terminal input bit did not stick" + }), + ..WindowsVirtualTerminalInputSetup::default() + }; } WindowsVirtualTerminalInputSetup { active: true, restore_mode: Some(mode), + warning: None, } } @@ -340,11 +412,13 @@ fn restore_terminal_state_once( reset_keyboard_enhancements: bool, reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool, - #[cfg(windows)] restore_windows_input_mode: Option<u32>, + #[cfg(windows)] restore_windows_input_mode: &WindowsInputModeRestore, ) -> io::Result<()> { if restore_claimed.swap(true, Ordering::AcqRel) { return Ok(()); } + #[cfg(windows)] + let restore_windows_input_mode = restore_windows_input_mode.take(); restore_terminal_state( reset_keyboard_enhancements, reset_modify_other_keys, @@ -443,6 +517,19 @@ fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Res } impl TerminalGuard { + #[cfg(windows)] + pub(super) fn recover_windows_virtual_terminal_input(&self) -> io::Result<()> { + let active = enable_windows_virtual_terminal_input( + &self.restore_claimed, + &self.restore_windows_input_mode, + ) + .active; + if active && windows_win32_input_mode_enabled() { + enable_windows_win32_input_mode(&mut io::stdout())?; + } + Ok(()) + } + /// Captures the restoration state for use by the process panic hook. pub(super) fn panic_restore(&self) -> impl Fn() + Send + Sync + 'static { let restore_claimed = self.restore_claimed.clone(); @@ -450,7 +537,7 @@ impl TerminalGuard { let reset_modify_other_keys = self.reset_modify_other_keys; let reset_host_color_scheme_reports = self.reset_host_color_scheme_reports; #[cfg(windows)] - let restore_windows_input_mode = self.restore_windows_input_mode; + let restore_windows_input_mode = self.restore_windows_input_mode.clone(); move || { let _ = restore_terminal_state_once( &restore_claimed, @@ -458,7 +545,7 @@ impl TerminalGuard { reset_modify_other_keys, reset_host_color_scheme_reports, #[cfg(windows)] - restore_windows_input_mode, + &restore_windows_input_mode, ); } } @@ -471,7 +558,7 @@ impl TerminalGuard { self.reset_modify_other_keys, self.reset_host_color_scheme_reports, #[cfg(windows)] - self.restore_windows_input_mode, + &self.restore_windows_input_mode, ) } } @@ -485,7 +572,7 @@ impl Drop for TerminalGuard { self.reset_modify_other_keys, self.reset_host_color_scheme_reports, #[cfg(windows)] - self.restore_windows_input_mode, + &self.restore_windows_input_mode, ); } } @@ -495,6 +582,51 @@ impl Drop for TerminalGuard { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn windows_input_mode_restore_tracks_recovery_after_cleanup_callback_creation() { + let restore_claimed = Arc::new(AtomicBool::new(false)); + let restore_mode = Arc::new(WindowsInputModeRestore::default()); + let callback_claimed = restore_claimed.clone(); + let callback_mode = restore_mode.clone(); + let cleanup = move || { + if callback_claimed.swap(true, Ordering::AcqRel) { + None + } else { + callback_mode.take() + } + }; + + let failed = + restore_mode.activate(&restore_claimed, WindowsVirtualTerminalInputSetup::default); + assert!(!failed.active); + let already_active = + restore_mode.activate(&restore_claimed, || WindowsVirtualTerminalInputSetup { + active: true, + ..WindowsVirtualTerminalInputSetup::default() + }); + assert!(already_active.active); + restore_mode.activate(&restore_claimed, || WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: Some(152), + warning: None, + }); + restore_mode.activate(&restore_claimed, || WindowsVirtualTerminalInputSetup { + active: true, + restore_mode: Some(999), + warning: None, + }); + + assert_eq!(cleanup(), Some(152)); + assert_eq!(cleanup(), None); + let called = AtomicBool::new(false); + restore_mode.activate(&restore_claimed, || { + called.store(true, Ordering::Release); + WindowsVirtualTerminalInputSetup::default() + }); + assert!(!called.load(Ordering::Acquire)); + } + #[derive(Clone, Default)] struct SharedOutput(std::rc::Rc<std::cell::RefCell<Vec<u8>>>); From 529eeca41d3136275d3f7d31c5d32d5912cc3ff3 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 17 Sep 2026 14:24:48 +0200 Subject: [PATCH 08/99] test: make windows config backup legacy fixture explicit (#4290) backup_preserves_legacy relied on a freshly created temp file already having a legacy DACL (D:(...)). Directories that grant inheritable ACEs produce D:AI(...) instead, so the fixture failed before exercising the backup path. Install the legacy DACL explicitly, preserving the existing ACEs while clearing the auto-inherit and protected control bits. --- src/platform/windows/config_backup/tests.rs | 40 +++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/platform/windows/config_backup/tests.rs b/src/platform/windows/config_backup/tests.rs index 966ba2a9..8a1b4bf2 100644 --- a/src/platform/windows/config_backup/tests.rs +++ b/src/platform/windows/config_backup/tests.rs @@ -3,8 +3,9 @@ use super::*; use std::process::Command; use windows_sys::Win32::{ Security::{ - DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, LABEL_SECURITY_INFORMATION, - OWNER_SECURITY_INFORMATION, + SetFileSecurityW, SetSecurityDescriptorControl, DACL_SECURITY_INFORMATION, + GROUP_SECURITY_INFORMATION, LABEL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + SE_DACL_AUTO_INHERITED, SE_DACL_AUTO_INHERIT_REQ, SE_DACL_PROTECTED, }, Storage::FileSystem::{ EncryptFileW, LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, @@ -83,11 +84,46 @@ fn lock_range(path: &Path, offset: u32) -> File { ); file } +/// Give an existing file a legacy DACL, preserving its ACEs but clearing the +/// control bits that mark it as inherited or protected. Temp directories often +/// grant inheritable ACEs, which would otherwise produce `D:AI(...)` and mean +/// this fixture never exercises the legacy `D:(...)` shape. +fn force_legacy_dacl(path: &Path) { + let mut descriptor = config_security_descriptor(path, DACL_SECURITY_INFORMATION).unwrap(); + assert_ne!( + unsafe { + SetSecurityDescriptorControl( + descriptor.as_mut_ptr().cast(), + SE_DACL_AUTO_INHERITED | SE_DACL_AUTO_INHERIT_REQ | SE_DACL_PROTECTED, + 0, + ) + }, + 0, + "{}", + io::Error::last_os_error() + ); + let wide = super::super::extended_length_path(path).unwrap(); + assert_ne!( + unsafe { + SetFileSecurityW( + wide.as_ptr(), + DACL_SECURITY_INFORMATION, + descriptor.as_mut_ptr().cast(), + ) + }, + 0, + "{}", + io::Error::last_os_error() + ); +} fn successful_update(case: &str) { let dir = Directory::new(case); let source = dir.0.join("config"); fs::write(&source, b"original preferences").unwrap(); fs::write(dir.0.join("config:private"), b"original stream").unwrap(); + if case == "legacy" { + force_legacy_dacl(&source); + } if case == "protected" || case == "unprotected" || case == "moved" { powershell( &format!( From 5c61d8ec2eeb296bd5e31bdd4e8f14ebadd5b7e1 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 17 Sep 2026 15:33:04 +0200 Subject: [PATCH 09/99] fix(windows): temporarily revert VT only mode on Windows (#4295) refs #4251 --- src/client/terminal_setup.rs | 2 +- src/client/tests/mod.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 1e072a4f..e888305d 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -501,7 +501,7 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> { pub(super) fn windows_win32_input_mode_enabled() -> bool { std::env::var("HERDR_WINDOWS_INPUT_PROBE") .map(|probe| probe.eq_ignore_ascii_case("win32")) - .unwrap_or(false) + .unwrap_or(true) } #[cfg(windows)] diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 1072bd30..2665e7fd 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -103,21 +103,21 @@ fn windows_virtual_terminal_input_mode_sets_only_vti_bit() { } #[test] -fn windows_win32_input_mode_defaults_to_vt_and_honors_probe() { +fn windows_win32_input_mode_defaults_to_win32_and_honors_probe() { let _guard = env_lock().lock().unwrap(); let _removed = EnvVarsRemovedGuard::new(&["HERDR_WINDOWS_INPUT_PROBE", "SSH_CONNECTION", "SSH_TTY"]); - assert!(!windows_win32_input_mode_enabled()); + assert!(windows_win32_input_mode_enabled()); { let _ssh = EnvVarGuard::set("SSH_CONNECTION", "1 2 3 4"); - assert!(!windows_win32_input_mode_enabled()); + assert!(windows_win32_input_mode_enabled()); let _probe = EnvVarGuard::set("HERDR_WINDOWS_INPUT_PROBE", "WiN32"); assert!(windows_win32_input_mode_enabled()); } { let _ssh = EnvVarGuard::set("SSH_TTY", "terminal"); - assert!(!windows_win32_input_mode_enabled()); + assert!(windows_win32_input_mode_enabled()); let _probe = EnvVarGuard::set("HERDR_WINDOWS_INPUT_PROBE", "vT"); assert!(!windows_win32_input_mode_enabled()); } From 101ccc20d3c6483a38ed9e0a1239b2ab908b1704 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 17 Sep 2026 17:45:07 +0200 Subject: [PATCH 10/99] fix(windows): prefer pwsh for the default pane shell (#4297) * fix(windows): prefer pwsh for the default pane shell Windows panes launched Windows PowerShell 5.1 (powershell.exe) whenever [terminal] default_shell was unset, ignoring PowerShell 7 even when pwsh.exe resolved on PATH. Resolve the unset default against PATH and prefer pwsh.exe, falling back to the inbox powershell.exe. An explicit default_shell still wins. * fix(windows): validate pwsh before using it as the default shell portable-pty resolves the configured shell with Path::exists and passes that path to CreateProcessW, which does not fall through to later PATH entries. Selecting an invalid pwsh.exe would therefore break new panes instead of falling back. Only prefer a pwsh.exe that starts with the PE MZ magic, and return its path so the validated binary is the one launched. * fix(windows): validate the full PE header before preferring pwsh Checking only the DOS MZ signature still accepted truncated images, DLLs, and foreign-architecture binaries. Because portable-pty passes the resolved path straight to CreateProcessW without trying later PATH entries, validate the PE signature, the COFF header (machine, executable-image bit, not a DLL, section count), and the optional header before preferring pwsh.exe. * fix(windows): accept native ARM64 pwsh and validate the section table Herdr ships an x64 Windows build that also runs on Windows ARM64 under x64 emulation, so cfg!(target_arch) cannot tell whether a native ARM64 pwsh.exe is launchable. Accept every executable machine type Windows supports instead of rejecting ARM64 on the x86_64 build. Also bound-check the section table (40 bytes per declared section) after the optional header so a truncated table falls back instead of being selected. * fix(windows): detect the native host machine for pwsh compatibility Machine compatibility was derived from cfg!(target_arch), which reports the emulated x64 process on Windows ARM64, and then briefly accepted ARM64 on every host. Read the native machine with IsWow64Process2 in the platform layer instead: ARM64 Windows accepts ARM64/x64/x86 images, x64 accepts x64/x86, and x86 accepts only x86. --- Cargo.toml | 1 + .../src/content/docs/configuration.mdx | 2 +- .../website/src/data/config-reference.json | 2 +- src/pane.rs | 324 +++++++++++++++++- src/platform/windows.rs | 33 +- 5 files changed, 354 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 09ceaf73..8c27a543 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,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/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 1975a43a..c54c79a9 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -73,7 +73,7 @@ Set the executable Herdr uses for newly created interactive panes: default_shell = "nu" ``` -When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix and PowerShell on Windows. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`. +When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix. On Windows it uses `pwsh.exe` (PowerShell 7) when it resolves on `PATH`, and otherwise the inbox `powershell.exe` (Windows PowerShell 5.1). This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`. Set how Herdr starts newly created interactive pane shells: diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 36afe337..cf102f4b 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -410,7 +410,7 @@ "key": "terminal.default_shell", "type": "string", "default": "\"\"", - "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh." + "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh; on Windows, pwsh.exe when present on PATH, otherwise powershell.exe." }, { "key": "terminal.shell_mode", diff --git a/src/pane.rs b/src/pane.rs index ec1b8646..29cbad2a 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1596,7 +1596,134 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String #[cfg(windows)] fn default_pane_shell() -> String { - "powershell.exe".into() + default_windows_pane_shell(std::env::var_os("PATH")) +} + +/// Windows has no `$SHELL`, so an unset `[terminal] default_shell` has to name +/// a concrete executable. `powershell.exe` (5.1) is the only one guaranteed to +/// exist, but `pwsh` (7+) is what every other Windows terminal prefers when it +/// is installed. Return the first launchable `pwsh.exe` on `PATH` as a full +/// path so the pane launches exactly the binary that was validated; keep the +/// inbox shell when none is found. An explicit `default_shell` still wins. +#[cfg(any(windows, test))] +fn default_windows_pane_shell(path: Option<std::ffi::OsString>) -> String { + path.as_deref() + .into_iter() + .flat_map(std::env::split_paths) + .filter(|dir| dir.is_absolute()) + .map(|dir| dir.join("pwsh.exe")) + .find(|candidate| is_windows_executable_file(candidate)) + .and_then(|path| path.into_os_string().into_string().ok()) + .unwrap_or_else(|| "powershell.exe".into()) +} + +/// Validate that `path` looks like a Windows executable image the current +/// machine can launch: a DOS `MZ` header whose `e_lfanew` points at a `PE\0\0` +/// signature with a non-empty, executable-image COFF header for a compatible +/// machine and a PE32/PE32+ optional header that fits in the file. +/// +/// `portable-pty` resolves the configured shell with `Path::exists` and hands +/// it straight to `CreateProcessW`, which does not fall back to later `PATH` +/// entries, so a malformed, DLL, or foreign-architecture `pwsh.exe` must be +/// skipped here instead of selected. Actually launching the candidate is the +/// only way to prove it loads, and this deliberately does not do that. +#[cfg(any(windows, test))] +fn is_windows_executable_file(path: &std::path::Path) -> bool { + is_windows_executable_file_for_host(path, windows_host_native_machine()) +} + +/// Native processor architecture of the host. Non-Windows builds only reach +/// this from the cross-platform unit tests, which model an x64 host. +#[cfg(any(windows, test))] +fn windows_host_native_machine() -> u16 { + #[cfg(windows)] + { + crate::platform::native_machine_type() + } + #[cfg(not(windows))] + { + 0x8664 + } +} + +#[cfg(any(windows, test))] +fn is_windows_executable_file_for_host(path: &std::path::Path, native_machine: u16) -> bool { + use std::io::{Read, Seek, SeekFrom}; + + const PE_OFFSET_FIELD: usize = 0x3c; + const DOS_HEADER_LEN: u64 = 0x40; + const PE_SIGNATURE: &[u8; 4] = b"PE\0\0"; + const COFF_HEADER_LEN: usize = 20; + const SECTION_HEADER_LEN: u64 = 40; + const OPTIONAL_HEADER_MAGIC_LEN: u64 = 2; + const IMAGE_FILE_EXECUTABLE_IMAGE: u16 = 0x0002; + const IMAGE_FILE_DLL: u16 = 0x2000; + + let Ok(mut file) = std::fs::File::open(path) else { + return false; + }; + let Ok(len) = file.metadata().map(|metadata| metadata.len()) else { + return false; + }; + + let mut dos_header = [0u8; DOS_HEADER_LEN as usize]; + if file.read_exact(&mut dos_header).is_err() || &dos_header[..2] != b"MZ" { + return false; + } + let pe_offset = u32::from_le_bytes([ + dos_header[PE_OFFSET_FIELD], + dos_header[PE_OFFSET_FIELD + 1], + dos_header[PE_OFFSET_FIELD + 2], + dos_header[PE_OFFSET_FIELD + 3], + ]) as u64; + let pe_header_len = PE_SIGNATURE.len() as u64 + COFF_HEADER_LEN as u64; + if pe_offset < DOS_HEADER_LEN || pe_offset.saturating_add(pe_header_len) > len { + return false; + } + if file.seek(SeekFrom::Start(pe_offset)).is_err() { + return false; + } + + let mut header = [0u8; 4 + COFF_HEADER_LEN]; + if file.read_exact(&mut header).is_err() || &header[..4] != PE_SIGNATURE { + return false; + } + let machine = u16::from_le_bytes([header[4], header[5]]); + let number_of_sections = u16::from_le_bytes([header[4 + 2], header[4 + 3]]); + let optional_header_len = u16::from_le_bytes([header[4 + 16], header[4 + 17]]) as u64; + let characteristics = u16::from_le_bytes([header[4 + 18], header[4 + 19]]); + + if !windows_executable_machine_is_compatible(machine, native_machine) + || number_of_sections == 0 + || characteristics & IMAGE_FILE_EXECUTABLE_IMAGE == 0 + || characteristics & IMAGE_FILE_DLL != 0 + || optional_header_len < OPTIONAL_HEADER_MAGIC_LEN + || pe_offset.saturating_add( + pe_header_len + optional_header_len + number_of_sections as u64 * SECTION_HEADER_LEN, + ) > len + { + return false; + } + + let mut magic = [0u8; 2]; + file.read_exact(&mut magic).is_ok() && matches!(u16::from_le_bytes(magic), 0x010b | 0x020b) +} + +/// Whether a Windows host can launch a given `IMAGE_FILE_MACHINE_*` image. +/// A native host runs its own architecture; ARM64 Windows also runs x64 and +/// x86 through emulation, and x64 Windows runs x86 through WOW64. An unknown +/// host (detection failed) is treated as the x64 build Herdr ships. +#[cfg(any(windows, test))] +fn windows_executable_machine_is_compatible(machine: u16, native_machine: u16) -> bool { + const MACHINE_I386: u16 = 0x014c; + const MACHINE_AMD64: u16 = 0x8664; + const MACHINE_ARM64: u16 = 0xaa64; + + match native_machine { + MACHINE_ARM64 => matches!(machine, MACHINE_I386 | MACHINE_AMD64 | MACHINE_ARM64), + MACHINE_I386 => machine == MACHINE_I386, + _ => matches!(machine, MACHINE_I386 | MACHINE_AMD64), + } } #[cfg(not(windows))] @@ -3802,6 +3929,201 @@ mod tests { assert_eq!(pane_shell_from("", None), default_pane_shell()); } + const TEST_EXECUTABLE_IMAGE: u16 = 0x0002; + const TEST_DLL: u16 = 0x2000; + const TEST_MACHINE_AMD64: u16 = 0x8664; + + /// Structurally valid PE32+ image, with fields chosen so tests can make it + /// malformed one way at a time. + fn windows_test_pe(machine: u16, characteristics: u16, number_of_sections: u16) -> Vec<u8> { + const PE_OFFSET: u32 = 0x80; + const COFF_HEADER_LEN: usize = 20; + const OPTIONAL_HEADER_LEN: u16 = 0x70; + const SECTION_HEADER_LEN: usize = 40; + + let pe = PE_OFFSET as usize; + let mut bytes = vec![ + 0u8; + pe + 4 + + COFF_HEADER_LEN + + OPTIONAL_HEADER_LEN as usize + + number_of_sections as usize * SECTION_HEADER_LEN + ]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&PE_OFFSET.to_le_bytes()); + bytes[pe..pe + 4].copy_from_slice(b"PE\0\0"); + bytes[pe + 4..pe + 6].copy_from_slice(&machine.to_le_bytes()); + bytes[pe + 4 + 2..pe + 4 + 4].copy_from_slice(&number_of_sections.to_le_bytes()); + bytes[pe + 4 + 16..pe + 4 + 18].copy_from_slice(&OPTIONAL_HEADER_LEN.to_le_bytes()); + bytes[pe + 4 + 18..pe + 4 + 20].copy_from_slice(&characteristics.to_le_bytes()); + bytes[pe + 24..pe + 26].copy_from_slice(&0x020bu16.to_le_bytes()); + bytes + } + + #[test] + fn windows_default_pane_shell_prefers_pwsh_on_path() { + let dir = std::env::temp_dir().join(format!( + "herdr-windows-default-shell-pwsh-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let pwsh = dir.join("pwsh.exe"); + let image = windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1); + std::fs::write(&pwsh, image).unwrap(); + let path = std::env::join_paths([&dir]).unwrap(); + + let resolved = default_windows_pane_shell(Some(path)); + let expected = pwsh.into_os_string().into_string().unwrap(); + + let _ = std::fs::remove_dir_all(dir); + assert_eq!(resolved, expected); + } + + #[test] + fn windows_default_pane_shell_falls_back_to_inbox_powershell() { + let dir = std::env::temp_dir().join(format!( + "herdr-windows-default-shell-fallback-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + // A file that is not a launchable executable must not be selected. + std::fs::write(dir.join("pwsh.exe"), b"not a PE image").unwrap(); + let path = std::env::join_paths([&dir]).unwrap(); + + let invalid = default_windows_pane_shell(Some(path)); + + let _ = std::fs::remove_dir_all(dir); + assert_eq!(invalid, "powershell.exe"); + assert_eq!(default_windows_pane_shell(None), "powershell.exe"); + } + + #[test] + fn windows_default_pane_shell_rejects_non_executable_pe_images() { + let dir = std::env::temp_dir().join(format!( + "herdr-windows-default-shell-invalid-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let pwsh = dir.join("pwsh.exe"); + let path = || std::env::join_paths([&dir]).unwrap(); + + let truncated_header = { + let mut bytes = b"MZ".to_vec(); + bytes.resize(0x40, 0); + bytes[0x3c..0x40].copy_from_slice(&0x50u32.to_le_bytes()); + bytes + }; + // One declared section, but the file ends before its 40-byte header. + let truncated_section_table = { + let mut bytes = windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1); + bytes.truncate(bytes.len() - 1); + bytes + }; + let cases: [(&str, Vec<u8>); 7] = [ + ("empty", Vec::new()), + ("dos signature only", b"MZ".to_vec()), + ("truncated pe header", truncated_header), + ("truncated section table", truncated_section_table), + ( + "dll", + windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE | TEST_DLL, 1), + ), + ( + "no executable bit", + windows_test_pe(TEST_MACHINE_AMD64, 0, 1), + ), + ( + "foreign machine", + windows_test_pe(0x0200, TEST_EXECUTABLE_IMAGE, 1), + ), + ]; + + for (label, bytes) in cases { + std::fs::write(&pwsh, &bytes).unwrap(); + assert_eq!( + default_windows_pane_shell(Some(path())), + "powershell.exe", + "case {label:?}" + ); + } + + std::fs::write( + &pwsh, + windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 0), + ) + .unwrap(); + assert_eq!( + default_windows_pane_shell(Some(path())), + "powershell.exe", + "case zero sections" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn windows_executable_machine_compatibility_tracks_native_host() { + const I386: u16 = 0x014c; + const AMD64: u16 = 0x8664; + const ARM64: u16 = 0xaa64; + + assert!(windows_executable_machine_is_compatible(I386, AMD64)); + assert!(windows_executable_machine_is_compatible(AMD64, AMD64)); + assert!(!windows_executable_machine_is_compatible(ARM64, AMD64)); + assert!(windows_executable_machine_is_compatible(I386, ARM64)); + assert!(windows_executable_machine_is_compatible(AMD64, ARM64)); + assert!(windows_executable_machine_is_compatible(ARM64, ARM64)); + assert!(windows_executable_machine_is_compatible(I386, I386)); + assert!(!windows_executable_machine_is_compatible(AMD64, I386)); + assert!(!windows_executable_machine_is_compatible(0x0200, AMD64)); + } + + #[test] + fn windows_executable_file_rejects_arm64_image_on_amd64_host() { + let dir = std::env::temp_dir().join(format!( + "herdr-windows-default-shell-arm64-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let pwsh = dir.join("pwsh.exe"); + std::fs::write(&pwsh, windows_test_pe(0xaa64, TEST_EXECUTABLE_IMAGE, 1)).unwrap(); + + let on_amd64 = is_windows_executable_file_for_host(&pwsh, 0x8664); + let on_arm64 = is_windows_executable_file_for_host(&pwsh, 0xaa64); + let on_x86 = is_windows_executable_file_for_host(&pwsh, 0x014c); + + let _ = std::fs::remove_dir_all(dir); + assert!(!on_amd64, "x64 Windows cannot launch a pure ARM64 image"); + assert!(on_arm64); + assert!(!on_x86); + } + + #[test] + fn windows_default_pane_shell_skips_invalid_pwsh_candidates() { + let base = std::env::temp_dir().join(format!( + "herdr-windows-default-shell-skip-{}", + std::process::id() + )); + let invalid_dir = base.join("invalid"); + let valid_dir = base.join("valid"); + std::fs::create_dir_all(&invalid_dir).unwrap(); + std::fs::create_dir_all(&valid_dir).unwrap(); + std::fs::write(invalid_dir.join("pwsh.exe"), b"MZ").unwrap(); + let pwsh = valid_dir.join("pwsh.exe"); + std::fs::write( + &pwsh, + windows_test_pe(TEST_MACHINE_AMD64, TEST_EXECUTABLE_IMAGE, 1), + ) + .unwrap(); + let path = std::env::join_paths([&invalid_dir, &valid_dir]).unwrap(); + + let resolved = default_windows_pane_shell(Some(path)); + let expected = pwsh.into_os_string().into_string().unwrap(); + + let _ = std::fs::remove_dir_all(base); + assert_eq!(resolved, expected); + } + #[test] fn shell_mode_auto_uses_login_shell_only_on_macos() { assert!(shell_mode_uses_login_shell( diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 9e39a715..cdf4acfc 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -350,11 +350,11 @@ use windows_sys::{ }, Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT}, Threading::{ - GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, OpenThread, - QueryFullProcessImageNameW, ResumeThread, TerminateProcess, CREATE_NO_WINDOW, - CREATE_SUSPENDED, DETACHED_PROCESS, PROCESS_BASIC_INFORMATION, - PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, - THREAD_SUSPEND_RESUME, + GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, IsWow64Process2, + OpenProcess, OpenThread, QueryFullProcessImageNameW, ResumeThread, + TerminateProcess, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS, + PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, THREAD_SUSPEND_RESUME, }, }, UI::{ @@ -386,6 +386,29 @@ const FOREGROUND_SELECTION_CACHE_CAPACITY: usize = 1_024; const FOREGROUND_SELECTION_CACHE_RETENTION: Duration = Duration::from_secs(60); const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID"; +/// Native processor architecture of the Windows host as an +/// `IMAGE_FILE_MACHINE_*` value. `IsWow64Process2` reports the native machine +/// even when an x64 Herdr runs under emulation on Windows ARM64, which the +/// build target alone cannot reveal. +pub(crate) fn native_machine_type() -> u16 { + let mut process_machine = 0u16; + let mut native_machine = 0u16; + // SAFETY: `GetCurrentProcess` returns a pseudo-handle and both out-pointers + // are valid for the duration of the call. + let ok = unsafe { + IsWow64Process2( + GetCurrentProcess(), + &mut process_machine, + &mut native_machine, + ) + }; + if ok != 0 && native_machine != 0 { + native_machine + } else { + process_machine + } +} + pub(crate) fn terminal_title_for_presentation(title: &str) -> &str { title.strip_prefix("Administrator: ").unwrap_or(title) } From 14351f121f6fc8c84b7c6c6eda78ad210e93cfcb Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 17 Sep 2026 22:43:35 +0200 Subject: [PATCH 11/99] test: skip windows symlink tests without symlink privilege (#4311) Windows refuses file symlinks with ERROR_PRIVILEGE_NOT_HELD (1314) unless the test process is elevated or Developer Mode is enabled, which broke non-admin local runs. Share a helper that reports the missing privilege so the two symlink tests skip only the symlink-dependent assertions. --- src/integration/config_file/tests.rs | 18 ++++------ src/integration/mod.rs | 2 ++ src/integration/test_support.rs | 49 ++++++++++++++++++++++++++++ src/integration/tests.rs | 13 +++++--- 4 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 src/integration/test_support.rs diff --git a/src/integration/config_file/tests.rs b/src/integration/config_file/tests.rs index 916edc67..687767ee 100644 --- a/src/integration/config_file/tests.rs +++ b/src/integration/config_file/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::integration::test_support::symlink_file; struct Directory(PathBuf); @@ -103,15 +104,6 @@ fn hard_links_are_rejected_before_staging_and_rechecked_before_commit() { assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 2); } -#[cfg(unix)] -fn symlink(target: &Path, link: &Path) { - std::os::unix::fs::symlink(target, link).unwrap(); -} -#[cfg(windows)] -fn symlink(target: &Path, link: &Path) { - std::os::windows::fs::symlink_file(target, link).unwrap(); -} - #[test] fn symlink_chains_and_dangling_targets_preserve_links() { let dir = Directory::new(); @@ -120,10 +112,12 @@ fn symlink_chains_and_dangling_targets_preserve_links() { let target = other.join("preferences"); let intermediate = other.join("link"); let entry = dir.0.join("config"); - symlink(&target, &intermediate); + if !symlink_file(&target, &intermediate) { + return; + } // A Windows reparse target needs native separators, unlike ordinary Win32 paths. let relative_target = Path::new("other").join("link"); - symlink(&relative_target, &entry); + assert!(symlink_file(&relative_target, &entry)); let original_intermediate = fs::read_link(&intermediate).unwrap(); assert_eq!( fs::metadata(&entry).unwrap_err().kind(), @@ -142,7 +136,7 @@ fn symlink_chains_and_dangling_targets_preserve_links() { assert_eq!(fs::read_link(&entry).unwrap(), relative_target); let cycle = dir.0.join("cycle"); - symlink(Path::new("cycle"), &cycle); + assert!(symlink_file(Path::new("cycle"), &cycle)); assert!(write_config(&cycle, b"must not replace the link").is_err()); assert_eq!(fs::read_link(&cycle).unwrap(), Path::new("cycle")); } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index daa714e0..349af16f 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -323,5 +323,7 @@ const GROK_INTEGRATION_VERSION: u32 = 2; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; +#[cfg(test)] +mod test_support; #[cfg(test)] mod tests; diff --git a/src/integration/test_support.rs b/src/integration/test_support.rs new file mode 100644 index 00000000..638beb00 --- /dev/null +++ b/src/integration/test_support.rs @@ -0,0 +1,49 @@ +//! Shared helpers for integration tests. + +use std::path::Path; + +/// Windows refuses file symlinks with `ERROR_PRIVILEGE_NOT_HELD` when the +/// process is neither elevated nor running with Developer Mode enabled. +#[cfg(windows)] +const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314; + +#[cfg(windows)] +fn symlink_privilege_denied(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD) +} + +/// Create a file symlink, returning `false` when Windows denied the symlink +/// privilege. Callers skip symlink-only assertions on `false` instead of +/// failing an ordinary non-elevated local test run. +#[cfg(unix)] +pub(super) fn symlink_file(target: &Path, link: &Path) -> bool { + std::os::unix::fs::symlink(target, link).expect("create symlink"); + true +} + +#[cfg(windows)] +pub(super) fn symlink_file(target: &Path, link: &Path) -> bool { + match std::os::windows::fs::symlink_file(target, link) { + Ok(()) => true, + Err(error) if symlink_privilege_denied(&error) => { + eprintln!("skipping symlink test: Windows denied SeCreateSymbolicLinkPrivilege"); + false + } + Err(error) => panic!("create symlink {}: {error}", link.display()), + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn symlink_privilege_error_is_detected() { + assert!(symlink_privilege_denied( + &std::io::Error::from_raw_os_error(ERROR_PRIVILEGE_NOT_HELD) + )); + assert!(!symlink_privilege_denied( + &std::io::Error::from_raw_os_error(/* ERROR_ACCESS_DENIED */ 5) + )); + } +} diff --git a/src/integration/tests.rs b/src/integration/tests.rs index f7e2154e..bffe2f45 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -4,6 +4,8 @@ use super::env::*; use super::file_ops::*; use super::registry::*; use super::targets::*; +#[cfg(windows)] +use super::test_support::symlink_file; use super::types::*; use super::version::*; use super::*; @@ -2565,11 +2567,12 @@ fn opencode_recovery_copy_blocks_retry_before_parsing_or_asset_changes() { fs::write(&referent, "{").unwrap(); let linked_backup = base.join("preferences.json.herdr-backup"); fs::rename(&backup, &linked_backup).unwrap(); - std::os::windows::fs::symlink_file(&referent, &config).unwrap(); - let link_before = fs::read_link(&config).unwrap(); - let error = install_target(target).unwrap_err(); - assert!(error.to_string().contains("preferences.json.herdr-backup")); - assert_eq!(fs::read_link(&config).unwrap(), link_before); + if symlink_file(&referent, &config) { + let link_before = fs::read_link(&config).unwrap(); + let error = install_target(target).unwrap_err(); + assert!(error.to_string().contains("preferences.json.herdr-backup")); + assert_eq!(fs::read_link(&config).unwrap(), link_before); + } assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration"); assert!(!dir.join("tui.jsonc").exists()); assert!(!dir.join(OPENCODE_V2_TUI_PLUGIN_DIR).exists()); From b88e8116e9e1f7e2f6e8b211536311cd17feca9a Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Fri, 18 Sep 2026 02:13:13 +0300 Subject: [PATCH 12/99] test: add local windows input gauntlet (#4298) * test: add local windows input gauntlet refs #4251 * test: add local windows input gauntlet Harden elevation and channel identity checks, distinguish known host limitations, and surface incomplete cleanup. refs #4251 * test(windows): harden input gauntlet execution refs #4251 * test(windows): make input gauntlet self-contained refs #4251 * test(windows): bound input gauntlet failure paths refs #4251 * test(windows): extend input qualification gauntlet refs #4251 * test(windows): complete input qualification matrix refs #4251 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com> --- justfile | 7 +- scripts/test_windows_input.ps1 | 415 ++++++++++++++++++++++++++ scripts/test_windows_input.py | 243 +++++++++++++++ scripts/windows_input/Bootstrap.ps1 | 51 ++++ scripts/windows_input/Common.ps1 | 88 ++++++ scripts/windows_input/Native.cs | 439 +++++++++++++++++++++++++++ scripts/windows_input/Probe.ps1 | 70 +++++ scripts/windows_input/README.md | 213 +++++++++++++ scripts/windows_input/report.py | 446 ++++++++++++++++++++++++++++ src/client/input.rs | 7 +- src/client/input/windows_vti.rs | 16 + 11 files changed, 1993 insertions(+), 2 deletions(-) create mode 100644 scripts/test_windows_input.ps1 create mode 100644 scripts/test_windows_input.py create mode 100644 scripts/windows_input/Bootstrap.ps1 create mode 100644 scripts/windows_input/Common.ps1 create mode 100644 scripts/windows_input/Native.cs create mode 100644 scripts/windows_input/Probe.ps1 create mode 100644 scripts/windows_input/README.md create mode 100644 scripts/windows_input/report.py diff --git a/justfile b/justfile index 5c42bee6..f736d8c1 100644 --- a/justfile +++ b/justfile @@ -13,9 +13,14 @@ test: # Run repository maintenance contract tests maintenance-test: - {{python}} -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_release scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty scripts.test_windows_cross + {{python}} -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_release scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty scripts.test_windows_cross scripts.test_windows_input bun test scripts/release-workflows.test.ts +# 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 {{args}} + # Run one nextest filter, e.g. `just test-one codex_stale_working` test-one filter: cargo nextest run --locked "{{filter}}" --status-level fail --final-status-level fail --failure-output final --success-output never diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 new file mode 100644 index 00000000..dfbd41b4 --- /dev/null +++ b/scripts/test_windows_input.ps1 @@ -0,0 +1,415 @@ +#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')][string[]] $Paths = @('direct', 'herdr'), + [string[]] $Cases, + [int[]] $Widths = @(80, 119, 120, 121, 132, 160, 240), + [int[]] $Heights = @(24, 50), + [string] $OutputDirectory, + [switch] $AllowInputInjection, + [switch] $Manual, + [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 } +$knownCases = @($matrix.cases | ForEach-Object id) +if (@($Cases).Count -and @($Cases | Where-Object { $_ -notin $knownCases }).Count) { throw 'Unknown case selection' } +$selectedCases = if (@($Cases).Count) { @($matrix.cases | Where-Object id -in $Cases) } else { @($matrix.cases) } +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)) +} +$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 "Modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')" +Write-Host "Channels: $($Channels -join ', '); paths: $($Paths -join ', '); cases: $(if (@($Cases).Count) { $Cases -join ', ' } else { 'all' })" +Write-Host "Clipboard formats at start: $([HerdrInputGauntlet.Desktop]::CountClipboardFormats()) (paste runs only when zero)" +$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 = @(); + 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 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 ($path in $Paths) { foreach ($mode in $Modes) { + $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 -eq 'herdr') { + $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) { $selectedCases } else { @($selectedCases | 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') -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 -eq 'herdr' -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 -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 + $row.final_outer_geometry = (Outer-State $plan).geometry + $row.status = 'observed'; $row.final_pane_geometry = $end.geometry + if ($path -eq 'herdr' -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 ($null -ne $clipboardSequence) { + if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($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($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 -eq 'herdr' -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" } + } + if (-not (Test-Path (Join-Path $work 'bootstrap-exit.json'))) { + 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..f8a70eee --- /dev/null +++ b/scripts/test_windows_input.py @@ -0,0 +1,243 @@ +"""Portable tests of the gauntlet's oracle, not Windows input qualification.""" +import copy +import unittest +from scripts.windows_input.report import 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") + + 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_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_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_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..4f6884fb --- /dev/null +++ b/scripts/windows_input/Bootstrap.ps1 @@ -0,0 +1,51 @@ +#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 -eq 'herdr') { + $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() + } + if ($plan.path -eq 'herdr') { + foreach ($verb in @('stop', 'delete')) { + try { $null = Invoke-GauntletProcess $plan.exe @('session', $verb, $plan.session) $plan } + catch { $cleanup += $_.ToString() } + } + } + 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..537b66ae --- /dev/null +++ b/scripts/windows_input/Common.ps1 @@ -0,0 +1,88 @@ +# 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 -eq 'herdr') { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + } + $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..5f941f1a --- /dev/null +++ b/scripts/windows_input/Native.cs @@ -0,0 +1,439 @@ +// 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")] public static extern int CountClipboardFormats(); + [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("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 uint SetEmptyClipboard(IntPtr owner,string text) { + 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(); } + // Closing can synthesize additional formats and advance the sequence. + // Reopen before adopting it so another writer cannot become our lease. + if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership"); + try { + if(owner==IntPtr.Zero || GetClipboardOwner()!=owner) throw new Exception("Clipboard ownership changed; refusing cleanup lease"); + return GetClipboardSequenceNumber(); + } finally { CloseClipboard(); } + } + public static bool ClearOwnedClipboard(uint sequence) { + if(!OpenClipboard(IntPtr.Zero)) return false; + try { return GetClipboardSequenceNumber()!=sequence || 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<IntPtr>(); + 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<deadline); + throw new Exception("Cannot focus test window; no input sent"); + } + public static void FocusAwayAndBack(IntPtr other,IntPtr hwnd,string nonce,int pid) { + if(other==IntPtr.Zero || other==hwnd) throw new Exception("No separate controller window for focus-cycle qualification"); + AssertNotElevated(Pid(other)); + var deadline=DateTime.UtcNow.AddSeconds(3); + do { + SetForegroundWindow(other); + if(GetForegroundWindow()==other) break; + Thread.Sleep(100); + } while(DateTime.UtcNow<deadline); + if(GetForegroundWindow()!=other) throw new Exception("Cannot focus controller window for focus-cycle qualification"); + Thread.Sleep(250); + Focus(hwnd,nonce,pid); + Thread.Sleep(250); + } + public static void Neutral() { + foreach(int key in new[]{0x10,0x11,0x12,0x5B,0x5C,1,2,4}) + if((GetAsyncKeyState(key)&0x8000)!=0) throw new Exception("Release physical modifiers and mouse buttons before running"); + } + static Input Event(ushort vk, bool release, IntPtr layout) { + uint mapped=MapVirtualKeyEx(vk,4,layout); + if(mapped==0) throw new Exception("No scan code for virtual key "+vk); + return new Input { Type=1, Data=new Union { Key=new Key { Scan=(ushort)(mapped&255), Flags=8u|(release?2u:0u)|((mapped&0xff00)!=0?1u:0u) } } }; + } + public static int[] Scans(IntPtr hwnd,int[] keys) { + uint ignored; var layout=GetKeyboardLayout(GetWindowThreadProcessId(hwnd,out ignored)); + var scans=new List<int>(); + 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<int>(); + 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<Input>(); + 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<Input>()); + if(sent!=events.Count) { + // Release only keys whose down event was actually submitted and not yet released. + var held=new List<Input>(); + for(int i=0;i<keys.Length && i<sent;i++) + if(sent <= 2*keys.Length-1-i) held.Add(Event((ushort)keys[i],true,layout)); + if(held.Count>0) { + uint released=SendInput((uint)held.Count,held.ToArray(),Marshal.SizeOf<Input>()); + 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<Input>())!=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<Input>()); + if(sent!=inputs.Length) { + int error=Marshal.GetLastWin32Error(); + if(sent==2 && SendInput(1,new[]{inputs[2]},Marshal.SizeOf<Input>())!=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,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<byte> bytes=new List<byte>(); + readonly List<long[]> records=new List<long[]>(); + 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[<u\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<count;i++) { + var r=batch[i]; + // Preserve every native record, including non-key event type and payload. + records.Add(new long[]{r.Type,r.Down,r.Repeat,r.Vk,r.Scan,r.Unicode,r.Control}); + } + } else { + var batch=new byte[4096]; + if(!ReadFile(input,batch,4096,out count,IntPtr.Zero)) { if(running) Error="ReadFile failed"; break; } + lock(gate) for(int i=0;i<count;i++) bytes.Add(batch[i]); + } + } + } catch(Exception e) { Error=e.Message; } + } + public void Clear() { lock(gate) { bytes.Clear(); records.Clear(); } } + public bool ClearIfCount(int expected) { + lock(gate) { + if(bytes.Count+records.Count!=expected) return false; + bytes.Clear(); records.Clear(); return true; + } + } + public string Hex() { lock(gate) return BitConverter.ToString(bytes.ToArray()).Replace("-","").ToLowerInvariant(); } + public long[][] Records() { lock(gate) return records.ToArray(); } + public int Count() { lock(gate) return bytes.Count+records.Count; } + public string[] CleanupErrors { get; private set; } = new string[0]; + public void Dispose() { + var errors=new List<string>(); + 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[<u\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..340ecb0e --- /dev/null +++ b/scripts/windows_input/README.md @@ -0,0 +1,213 @@ +# 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 at least +one Windows Terminal channel. 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. + +Clipboard tests require an **empty clipboard**. Clear it yourself only after +saving anything you need. The runner will not replace existing text, images, +rich formats, or files. It writes its synthetic text 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 full catalogue normally exits `2` because operator-assisted and explicitly +unimplemented qualification cases remain. That is incomplete coverage, not an +automated test failure; inspect the printed matrix and retained `report.json`. + +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 ` + -StablePath 'C:\TerminalStable\WindowsTerminal.exe' ` + -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe' +``` + +The default tests current Herdr's **default** input policy. Diagnostic runs may +use `-Profile win32` or `-Profile vt`; they do not replace the default run. +`-Modes native,legacy,mok2,kitty`, `-Channels`, `-Paths`, `-Cases`, `-Widths`, and +`-Heights` select a focused campaign. In a direct PowerShell invocation, supply +arrays normally: + +```powershell +.\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' ` + -AllowInputInjection -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` + -Widths 120 -Heights 30 +``` + +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/<unique-id>/`. 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. +- A full case pass at an observed 120×30 host size; 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; 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. 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`: coverage is incomplete (including deliberately unimplemented catalogue + rows, missing hosts, or unavailable geometry). + +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..7c15c149 --- /dev/null +++ b/scripts/windows_input/report.py @@ -0,0 +1,446 @@ +"""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="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."), + ("clipboard-nontext", "Qualify supported image/file clipboard integrations separately; do not infer from text paste."), + ]: + cases.append(dict(id=name, kind="qualification", prompt=prompt, expected={})) + return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases) + + +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 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("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"]] + 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)] + selected_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 phase, (width, height, full) in enumerate(geometries, 1): + for case_id in selected_cases if full else (case_id for case_id in ("letter-a", "shift-enter", "paste-lf", "mouse-focus-refresh") if case_id in selected_cases): + planned.add((host, path, 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)) + 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", []) + 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), + ("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), + ] + + def cell(case_ids, width, path, modes): + 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 (width is None or row.get("width") == width)] + statuses = {row.get("status") for row in matched} + if "fail" in statuses: + return "FAIL" + if case_ids <= manual_cases and not any(status == "pass" for status in statuses): + return "MANUAL" + 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" + if required_channels and {(row.get("host"), row.get("case")) for row in matched} != { + (channel, case_id) for channel in required_channels for case_id in case_ids}: + 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 == {"unsupported"}: + return "X - becomes Enter" if case_ids == {"shift-enter"} else "X - loses modifier" + def case_passed(case_id): + statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id} + return "pass" in statuses_for_case or (width == 80 and path == "direct" and modes == {"legacy"} + and case_id == "shift-enter" and statuses_for_case == {"unsupported"}) + per_case_passed = all(case_passed(case_id) for case_id in case_ids) + 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: + 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"} + table.append((name, cell(case_ids, width, "herdr", herdr_modes), + cell(case_ids, width, "direct", {"legacy"}), cell(case_ids, width, "direct", {"kitty"}))) + return table + + +def herdr_protocol_label(result): + 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") == "herdr" and run.get("nonce")} + proven = {(row.get("host"), row.get("path"), row.get("mode"), row.get("nonce")) + for row in result.get("observations", []) + if row.get("path") == "herdr" and row.get("input_reader") == "windows-console" + and row.get("input_transport") == "win32-serialized" and row.get("nonce")} + return "Win32 (Herdr)*" if runs and runs <= proven else "Herdr default (UNKNOWN)*" + + +def print_qualification_matrix(result): + table = [("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(4)] + 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.") + 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") == "herdr" 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 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"])) + 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/src/client/input.rs b/src/client/input.rs index 848d0156..41850f96 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -327,13 +327,18 @@ fn windows_stdin_reader_loop( should_quit: &Arc<AtomicBool>, ) { if !super::windows_vti_input_backend_enabled() { + windows_vti::trace_input_transport("reader=crossterm"); windows_crossterm_reader_loop(event_tx, should_quit); } else { match windows_vti::console_input_handle() { Ok(handle) => { + windows_vti::trace_input_transport("reader=windows-console"); windows_vti::raw_console_reader_loop(handle, event_tx, should_quit); } - _ => windows_crossterm_reader_loop(event_tx, should_quit), + _ => { + windows_vti::trace_input_transport("reader=crossterm-fallback"); + windows_crossterm_reader_loop(event_tx, should_quit); + } } } } diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 48fe8220..2b92c167 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -4,6 +4,8 @@ use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(windows)] use std::sync::Arc; +#[cfg(windows)] +use std::{fs::OpenOptions, io::Write as _}; #[cfg(windows)] use tokio::sync::mpsc; @@ -56,6 +58,19 @@ pub(super) fn raw_console_reader_loop( } } +#[cfg(windows)] +pub(super) fn trace_input_transport(value: &str) { + let Some(path) = std::env::var_os("HERDR_WINDOWS_INPUT_TRACE_FILE") else { + return; + }; + if let Ok(mut output) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(output, "{value}"); + } +} + +#[cfg(not(windows))] +fn trace_input_transport(_value: &str) {} + #[cfg(windows)] fn process_platform_input_items( items: Vec<PlatformInputItem>, @@ -774,6 +789,7 @@ impl WindowsInputMapper { items.push(PlatformInputItem::Bytes(bytes)) } WindowsWin32InputModeItem::Key { bytes, record } => { + trace_input_transport("transport=win32-serialized"); let win32_paste_bytes = self.paste_payload_bytes_for_key(record).unwrap_or_default(); if let Some(raw_bytes) = self.win32_input_mode_key_record_raw_bytes(record) { From da6bcd5969779bfe0396bcf89a8025d4375d611e Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Fri, 18 Sep 2026 02:42:37 +0200 Subject: [PATCH 13/99] fix(windows): preserve mouse capture during refresh (#4319) * fix(windows): preserve mouse capture during refresh refs #4284 * test(windows): fix default input gauntlet invocation * fix(windows): preserve pixel mouse transitions refs #4284 --- scripts/test_windows_input.ps1 | 4 +++- scripts/windows_input/README.md | 10 ++++++++++ src/client/terminal_setup.rs | 25 ++++++++++++++++--------- src/terminal_modes.rs | 21 ++++++++++++--------- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index dfbd41b4..28839a07 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -29,8 +29,10 @@ $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 (@($Cases).Count -and @($Cases | Where-Object { $_ -notin $knownCases }).Count) { throw 'Unknown case selection' } +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) } 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' } diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index 340ecb0e..36caf3f6 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -51,6 +51,16 @@ The full catalogue normally exits `2` because operator-assisted and explicitly unimplemented qualification cases remain. That is incomplete coverage, not an automated test failure; inspect the printed matrix and retained `report.json`. +### Expected outcome + +A healthy run builds and identifies the current checkout, reaches the result +matrix, and reports no `FAIL` rows or cleanup errors. The default Herdr column +should identify itself as `Win32 (Herdr)*` from captured runtime evidence. A full +run may still exit `2` and show `MANUAL`, `NOT TESTED`, or a documented host +capability limitation when a terminal channel, physical gesture, or oracle is +unavailable. A focused campaign exits `0` when every selected observation passes; +exit `1` means an assertion, harness, or cleanup failure and needs investigation. + 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 diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index e888305d..3bb5c281 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -348,7 +348,9 @@ fn set_windows_native_mouse_capture<W: io::Write>( sgr_pixels: bool, set_console_capture: impl FnOnce(bool) -> io::Result<()>, ) -> io::Result<()> { - crate::terminal_modes::clear_host_mouse_reporting(writer)?; + if !enabled { + crate::terminal_modes::clear_host_mouse_reporting(writer)?; + } set_console_capture(enabled)?; if enabled { crate::terminal_modes::set_windows_mouse_reporting(writer, true, sgr_pixels)?; @@ -365,7 +367,9 @@ fn windows_uses_vt_mouse_reporting() -> bool { pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { #[cfg(windows)] if windows_uses_vt_mouse_reporting() { - crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + if !enabled { + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; + } return crate::terminal_modes::set_windows_mouse_reporting( &mut io::stdout(), enabled, @@ -642,7 +646,7 @@ mod tests { } #[test] - fn windows_native_mouse_capture_never_resets_encoding_after_native_enable() { + fn windows_native_mouse_capture_reasserts_final_encoding_after_native_enable() { let mut output = SharedOutput::default(); for sgr_pixels in [false, false, true, false] { let start = output.0.borrow().len(); @@ -663,8 +667,8 @@ mod tests { let bytes = output.0.borrow(); let before = std::str::from_utf8(&bytes[start..native_boundary.get()]).unwrap(); let after = std::str::from_utf8(&bytes[native_boundary.get()..]).unwrap(); - assert!(before.contains("\x1b[?1016l")); - for reset in ["\x1b[?1005l", "\x1b[?1006l", "\x1b[?1016l"] { + assert!(!before.contains("\x1b[?1016l")); + for reset in ["\x1b[?1005l", "\x1b[?1006l"] { assert!( !after.contains(reset), "mouse format reset after native capture (sgr_pixels={sgr_pixels}): {after:?}" @@ -672,22 +676,25 @@ mod tests { } assert!(after.contains("\x1b[?1003h\x1b[?1006h")); assert_eq!(after.contains("\x1b[?1016h"), sgr_pixels); + if !sgr_pixels { + assert!(after.starts_with("\x1b[?1016l")); + } } } #[test] - fn windows_native_mouse_capture_restores_reporting_after_reset() { + fn windows_native_mouse_capture_disable_clears_reporting() { let mut output = Vec::new(); - set_windows_native_mouse_capture(&mut output, true, false, |enabled| { - assert!(enabled); + set_windows_native_mouse_capture(&mut output, false, false, |enabled| { + assert!(!enabled); Ok(()) }) .unwrap(); assert_eq!( output, - b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" + b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?9l" ); } } diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs index 11bf56fc..b3973209 100644 --- a/src/terminal_modes.rs +++ b/src/terminal_modes.rs @@ -21,15 +21,18 @@ pub(crate) fn set_windows_mouse_reporting<W: Write>( enabled: bool, sgr_pixels: bool, ) -> io::Result<()> { - writer.write_all(if enabled { - WINDOWS_MOUSE_REPORTING_ENABLE_SEQUENCE + if enabled { + // Keep a true-to-false pixel transition in the same buffered reassertion, + // before the final SGR enable, so refresh never exposes a disabled gap. + if !sgr_pixels { + writer.write_all(b"\x1b[?1016l")?; + } + writer.write_all(WINDOWS_MOUSE_REPORTING_ENABLE_SEQUENCE)?; + if sgr_pixels { + writer.write_all(b"\x1b[?1016h")?; + } } else { - WINDOWS_MOUSE_REPORTING_DISABLE_SEQUENCE - })?; - // The caller clears modes before native capture. Resetting 1016 here can - // undo the SGR encoding that ConPTY has already enabled on the host. - if enabled && sgr_pixels { - writer.write_all(b"\x1b[?1016h")?; + writer.write_all(WINDOWS_MOUSE_REPORTING_DISABLE_SEQUENCE)?; } writer.flush() } @@ -195,7 +198,7 @@ mod tests { assert_eq!( output, - b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l" + b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016h\x1b[?1016l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?1016l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l" ); } } From 7df919d00e5bf8f6ed43a1781e81340cc0bbce8d Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Fri, 18 Sep 2026 12:52:37 +0300 Subject: [PATCH 14/99] fix: correct grok activity detection with custom or disabled osc signals (#4337) refs #4333 --- distribution/agent-detection/grok.toml | 39 +++++--- scripts/agent_detection_manifest_check.py | 6 +- src/detect/manifest/tests.rs | 117 ++++++++++++++++++++++ src/detect/manifests/grok.toml | 39 +++++--- 4 files changed, 174 insertions(+), 27 deletions(-) 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 # "<session> - 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/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/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index bdb0789d..fc519c7f 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -725,6 +725,123 @@ fn osc_explain( ) } +#[test] +fn grok_osc_activity_does_not_depend_on_configurable_title_text() { + for (title, progress, expected_state, expected_rule) in [ + ( + "project · session · id", + "4;0;0", + AgentState::Idle, + "osc_progress_idle", + ), + ( + "project · session · id", + "", + AgentState::Idle, + "prompt_hints_idle", + ), + ("grok", "", AgentState::Idle, "osc_title_idle"), + ( + "⠋ - Waiting for response… - project", + "", + AgentState::Working, + "osc_title_working", + ), + ( + "project - ⠹ - session", + "4;0;0", + AgentState::Working, + "osc_title_working", + ), + ( + "project · session · id", + "4;1;-1", + AgentState::Working, + "osc_progress_working", + ), + ( + "grok", + "4;1;-1", + AgentState::Working, + "osc_progress_working", + ), + ( + "⚠ Action Required - project", + "4;1;-1", + AgentState::Blocked, + "osc_title_blocked", + ), + ] { + let result = osc_explain( + Agent::Grok, + "Shift+Tab:mode │ Ctrl+.:shortcuts\n", + title, + progress, + ); + assert_eq!( + result.state, expected_state, + "title={title}, progress={progress}" + ); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some(expected_rule), + "title={title}, progress={progress}", + ); + } +} + +#[test] +fn grok_visible_activity_outranks_idle_osc_signals() { + for (screen, expected_rule) in [ + ( + "⠴ Sleep for 8 … 1.9s 5.0s ⇣19.5k [↓][stop]\n", + "spinner_status_working", + ), + ( + "Shift+Tab:mode │ Ctrl+c:cancel │ Ctrl+.:shortcuts\n", + "esc_cancel_hints_working", + ), + ( + "Shift+Tab:mode │ Esc:cancel │ Ctrl+.:shortcuts\n", + "esc_cancel_hints_working", + ), + ("◎ 1 command still running\n", "background_status_working"), + ("○ 1 command still running\n", "background_status_working"), + ("◉ 1 command still running\n", "background_status_working"), + ( + "◎ 2 commands · 1 subagent still running · send a message to interrupt\n", + "background_status_working", + ), + ] { + for progress in ["", "4;0;0"] { + let result = osc_explain(Agent::Grok, screen, "grok", progress); + assert_eq!(result.state, AgentState::Working, "screen={screen}"); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some(expected_rule) + ); + } + let blocked = osc_explain(Agent::Grok, screen, "⚠ Action Required - grok", "4;1;-1"); + assert_eq!(blocked.state, AgentState::Blocked); + } +} + +#[test] +fn grok_background_activity_requires_a_live_nonzero_status_row() { + for screen in [ + "1 command still running\n", + "◎ 0 commands still running\n", + "Discussed: ◎ 1 command still running\n", + "◎ 1 command still running\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n", + "Worked for 3.9s\nShift+Tab:mode │ Ctrl+.:shortcuts\n", + ] { + assert_eq!( + osc_explain(Agent::Grok, screen, "project · session", "4;0;0").state, + AgentState::Idle + ); + } +} + // --- Claude OSC rules --- #[test] diff --git a/src/detect/manifests/grok.toml b/src/detect/manifests/grok.toml index 30b6e9c7..67ba4ebc 100644 --- a/src/detect/manifests/grok.toml +++ b/src/detect/manifests/grok.toml @@ -1,16 +1,16 @@ id = "grok" -version = "2026.07.16.2" +version = "2026.09.18.2" min_engine_version = 3 -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 # "<session> - 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. @@ -85,6 +85,16 @@ region = "top_non_empty_lines(1)" visible_working = true line_regex = ['[⋅:⸬⁙.·]\s+[1-9][0-9]*\s+│'] +# 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" @@ -104,16 +114,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" @@ -123,10 +132,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*$'] @@ -134,16 +145,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 = [ From 7201907b654aefdb9b636b17ea7ca3cddc873d7b Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Fri, 18 Sep 2026 14:09:12 +0300 Subject: [PATCH 15/99] test: focus agent detection coverage on engine contracts (#4338) --- AGENTS.md | 4 +- src/detect/manifest/tests.rs | 1177 ++++++---------------------------- src/detect/mod.rs | 103 --- 3 files changed, 211 insertions(+), 1073 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 992b6592..7901fb7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,9 @@ manual testing, reset `C:\work\repo` back to a clean checkout before finishing. Agent detection changes should use the manifest hot-reload loop. Use the project-local `herdr-throwaway-repro` skill to create a disposable named session and drive the real agent UI through Herdr's CLI/API into the target state. Read the pane with `herdr agent read <pane> --source detection --format text` and inspect matching with `herdr agent explain <pane> --json`. Update the bundled manifest in `src/detect/manifests/<agent>.toml`, copy that manifest to the local override path at `~/.config/herdr/agent-detection/<agent>.toml`, then run `herdr server reload-agent-manifests` against the session under test. Before writing the override, check whether one already exists; never overwrite or remove a pre-existing override without alignment. Once the rule is correct, remove the temporary override or restore the previous one exactly so the committed bundled manifest remains the source of truth. -Do not add large agent-specific full-screen fixture suites for routine manifest tuning. Keep Rust tests focused on manifest parsing, rule semantics, skip-state semantics, source precedence, cache reload behavior, and update flow. Use live pane reads for agent-specific screen evidence. +Unit-test Herdr's 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. `distribution/agent-detection/` is the remotely published catalog for released clients. Keep changes for already released agents aligned with their bundled manifests unless the validator records an exact compatibility exception. A newly bundled agent that current stable clients cannot identify may remain unpublished behind an exact version-and-digest exception, but it must be added to the catalog and the exception removed before the first stable release that ships it. `just release-docs-check` enforces that no unpublished exceptions remain. diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index fc519c7f..07e9aa38 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -1,5 +1,6 @@ use super::*; +// Codex is only a registry key here; behavior tests supply synthetic rules. fn remote_manifest(version: &str, state: &str, contains: &str) -> String { format!( r#" @@ -89,14 +90,17 @@ fn write_local_codex(content: &str) { #[test] fn known_agent_no_match_defaults_to_idle_fallback() { - let explain = explain(Agent::Codex, "ordinary prompt text"); + with_manifest_dirs("no-match", || { + write_local_codex(&local_manifest("working", "active-marker")); + let explain = explain(Agent::Codex, "unmatched-marker"); - assert_eq!(explain.state, AgentState::Idle); - assert!(!explain.visible_idle); - assert_eq!( - explain.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); + assert_eq!(explain.state, AgentState::Idle); + assert!(!explain.visible_idle); + assert_eq!( + explain.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); + }); } #[test] @@ -200,7 +204,6 @@ fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() { let explain = explain(Agent::Codex, "remote-ready"); - assert_eq!(explain.state, AgentState::Idle); assert!(matches!(explain.source, Some(ManifestSource::Bundled))); assert_eq!( explain.cached_remote_version.as_deref(), @@ -358,119 +361,209 @@ fn compiled_rules_are_shared_until_manifest_reload() { } #[test] -fn all_bundled_manifests_parse_and_validate() { - for agent in Agent::SCREEN_MANIFEST_AGENTS { - assert!( - bundled_manifest(agent).is_some(), - "missing bundled manifest for {}", - agent_label(agent) +fn osc_regions_use_separate_inputs_and_share_rule_priority() { + with_manifest_dirs("osc-regions", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "screen" +state = "idle" +priority = 10 +region = "whole_recent" +visible_idle = true +contains = ["screen-marker"] + +[[rules]] +id = "title" +state = "working" +priority = 20 +region = "osc_title" +visible_working = true +regex = ['^title-marker$'] + +[[rules]] +id = "progress" +state = "blocked" +priority = 30 +region = "osc_progress" +visible_blocker = true +regex = ['^progress-marker$'] +"#, + )); + for (screen, title, progress, state, rule) in [ + ("screen-marker", "", "", AgentState::Idle, "screen"), + ( + "screen-marker", + "title-marker", + "", + AgentState::Working, + "title", + ), + ( + "screen-marker", + "title-marker", + "progress-marker", + AgentState::Blocked, + "progress", + ), + ( + "screen-marker title-marker progress-marker", + "", + "", + AgentState::Idle, + "screen", + ), + ] { + let input = DetectionInput { + screen, + osc_title: title, + osc_progress: progress, + }; + let result = explain_with_input(Agent::Codex, input); + assert_eq!(result.state, state); + assert_eq!( + result + .matched_rule + .as_ref() + .map(|matched| matched.id.as_str()), + Some(rule) + ); + let detection = + crate::detect::detect_agent_with_osc(Some(Agent::Codex), screen, title, progress); + assert_eq!(detection.state, state); + assert_eq!(detection.visible_idle, state == AgentState::Idle); + assert_eq!(detection.visible_working, state == AgentState::Working); + assert_eq!(detection.visible_blocker, state == AgentState::Blocked); + } + let swapped = explain_with_input( + Agent::Codex, + DetectionInput { + screen: "", + osc_title: "progress-marker", + osc_progress: "title-marker", + }, ); - } + assert!(swapped.matched_rule.is_none()); + }); } #[test] -fn devin_manifest_detects_idle_working_and_blocked_states() { - let idle = explain( - Agent::Devin, - "─────────────────────────────────────────────────────\n❭ Ask Devin to build features, fix bugs, or work on\n your code\n─────────────────────────────────────────────────────\nSWE-1.6 Context: 16k / 200k tokens (7%)", - ); - assert_eq!(idle.state, AgentState::Idle); - assert!(idle.visible_idle); - - let live_footer_idle = explain( - Agent::Devin, - "Done.\n\n────────────────────────────────────────────────── (bypass permissions on) ─\n❭\n────────────────────────────────────────────────────────────────────────────\nClaude Opus 4.6 Thinking Context: 38k / 200k tokens (18%)", - ); - assert_eq!(live_footer_idle.state, AgentState::Idle); - assert_eq!( - live_footer_idle - .matched_rule - .as_ref() - .map(|rule| rule.id.as_str()), - Some("live_prompt_footer") - ); - assert!(live_footer_idle.visible_idle); - - let welcome_footer_idle = explain( - Agent::Devin, - "⠀⠀⠀⠀⠀⣴⣾⣶⡄⠀⠀⠀⠀\n⠀⣴⣾⣶⡾⠛⠿⠟⠃⣴⣾⣶⡄ Devin CLI\n⠀⠛⠿⠟⠃⣴⣾⣶⡾⠛⠿⠟⠃ v2026.5.26-8\n⠀⣤⣶⣦⡄⠻⢿⠿⢷⣤⣶⣦⡄\n⠀⠻⢿⠿⢷⣤⣶⣦⡄⠻⢿⠿⠃ Hybrid\n⠀⠀⠀⠀⠀⠻⢿⠿⠃⠀⠀⠀⠀\n\n───────────────────────────\n❭ Ask Devin to build\n features, fix bugs, or\n work on your code\n───────────────────────────\nClaude Opus Looking for\n4.6 Thinkingplan mode? /\n plan", - ); - assert_eq!(welcome_footer_idle.state, AgentState::Idle); - assert_eq!( - welcome_footer_idle - .matched_rule - .as_ref() - .map(|rule| rule.id.as_str()), - Some("welcome_prompt_footer") - ); - assert!(welcome_footer_idle.visible_idle); - - let working = explain( - Agent::Devin, - "◔ Reading shell 91b655\n │ Timeout: 35s\n\n⠀⡆ Running tools · 27s (esc to interrupt)\n─────────────────────────────────────────────────────\n❭ Guide Devin while it works", - ); - assert_eq!(working.state, AgentState::Working); - assert!(working.visible_working); - - let trust_prompt = explain( - Agent::Devin, - "Do you trust the authors of this directory?\nFor security, devin should not be run in directories\nwith untrusted content.\n❭ 1 Yes, trust /private/tmp/devin-hook-probe\n· 2 No, exit", - ); - assert_eq!(trust_prompt.state, AgentState::Blocked); - assert!(trust_prompt.visible_blocker); +fn skip_rule_suppresses_state_update_without_visible_state_evidence() { + with_manifest_dirs("skip-rule", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "activity" +state = "working" +priority = 10 +visible_working = true +contains = ["activity-marker"] - let permission_prompt = explain( - Agent::Devin, - "⏺ Running command\n └ $ sleep 30\n\n❭ 1 Yes (Approve once)\n· 2 Yes, allow `sleep` commands\n· 3 Yes, always allow `sleep` commands\n· 4 No\n↑↓ select · ↵ confirm · esc cancel", - ); - assert_eq!(permission_prompt.state, AgentState::Blocked); - assert!(permission_prompt.visible_blocker); +[[rules]] +id = "overlay" +state = "unknown" +priority = 20 +skip_state_update = true +contains = ["overlay-marker"] +"#, + )); + let screen = "activity-marker overlay-marker"; + let result = explain(Agent::Codex, screen); + assert_eq!(result.state, AgentState::Unknown); + assert!(result.skip_state_update); + assert_eq!( + result.skipped_update_reason.as_deref(), + Some("matched_rule:overlay") + ); + assert!(!result.visible_idle); + assert!(!result.visible_working); + assert!(!result.visible_blocker); + assert!(detect(Agent::Codex, screen).skip_state_update); + assert!(should_skip_state_update(Agent::Codex, screen)); + assert!(!should_skip_state_update(Agent::Codex, "activity-marker")); + }); } #[test] -fn muse_manifest_requires_complete_live_controls() { - let working = explain( - Agent::Muse, - "⟩ hello\n\n◆ Working (0s · esc to interrupt)\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(working.state, AgentState::Working); - assert!(working.visible_working); - - let picker = explain( - Agent::Muse, - "Which option should I use?\n\n› 1. Alpha\n 2. Beta\n\nEnter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(picker.state, AgentState::Blocked); - assert!(picker.visible_blocker); - - let command_approval = explain( - Agent::Muse, - "Would you like to run the following command?\n\n$ printf muse-safe-probe\n\n› 1. Allow this stage once (y)\n 2. Always allow in this workspace: printf muse-safe-probe ... (p)\n 3. Abort the entire command (esc)\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(command_approval.state, AgentState::Blocked); - assert!(command_approval.visible_blocker); - - let network_approval = explain( - Agent::Muse, - "network: example.com:443 https\nrequested by:\n$ curl -fsS https://example.com\n\n› 1. Yes, proceed (y)\n 2. Yes, don't ask again this session (p) example.com:443 (https)\n 3. No, and tell Muse Code what to do differently (esc)\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(network_approval.state, AgentState::Blocked); - assert!(network_approval.visible_blocker); - - let menu = explain( - Agent::Muse, - "Theme\n\n⟩ Default (active)\n Dynamic\n\n↑↓ move · enter save · esc go back", - ); - assert_eq!(menu.state, AgentState::Unknown); - assert!(menu.skip_state_update); - assert!(!menu.visible_blocker); +fn screen_regions_extract_structure_without_classifying_agent_state() { + for (screen, spec, expected) in [ + ("old\n\nnew\n", "bottom_lines(2)", "\nnew\n"), + ( + "before\n› input\nafter\n", + "after_last_prompt_marker", + "after\n", + ), + ( + "before\n› input\nafter\n", + "before_current_prompt_marker", + "before\n", + ), + ( + "before\n› input\nafter\n", + "whole_recent_without_current_prompt_marker", + "", + ), + ( + "no marker\n", + "whole_recent_without_current_prompt_marker", + "no marker\n", + ), + ( + "• old\n■ latest\n› input\n", + "current_prompt_block_marker", + "■ latest", + ), + ( + "• old\n■ latest\n› input\n", + "after_current_prompt_block_marker", + "■ latest\n› input\n", + ), + ("› old\n• new\n", "current_prompt_block_marker", ""), + ( + "above\n\n───\nbody\n───\nfooter\n", + "above_prompt_box", + "above\n\n", + ), + ( + "above\n\n───\nbody\n───\nfooter\n", + "last_non_empty_above_prompt_box", + "above", + ), + ( + "above\n───\nbody\n───\nfooter\n", + "prompt_box_body", + "body\n", + ), + ( + "above\n───\nbody\n───\nfooter\n", + "after_last_horizontal_rule", + "footer\n", + ), + ] { + assert_eq!( + region( + DetectionInput { + screen, + osc_title: "", + osc_progress: "" + }, + spec + ), + expected, + "region={spec}" + ); + } +} - let ordinary_reply = explain( - Agent::Muse, - "⟩ say the phrase\n\n◆ Yes, proceed\n\n────────────────\n⟩\n────────────────\ngpt-5.4 · minimal · /workspace", - ); - assert_eq!(ordinary_reply.state, AgentState::Idle); - assert!(ordinary_reply.visible_idle); +#[test] +fn all_bundled_manifests_parse_and_validate() { + for agent in Agent::SCREEN_MANIFEST_AGENTS { + assert!( + bundled_manifest(agent).is_some(), + "missing bundled manifest for {}", + agent_label(agent) + ); + } } #[test] @@ -486,7 +579,6 @@ contain = ["Working"] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -497,7 +589,6 @@ state = "working" "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -510,7 +601,6 @@ contains = ["Working"] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -522,7 +612,6 @@ regex = ["["] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -550,7 +639,6 @@ contains = ["menu"] "# ) .is_err()); - assert!(parse_manifest( r#" id = "codex" @@ -583,7 +671,6 @@ contains = ["ready"] "# )); } - assert!(parse_manifest(&manifest).is_err()); } @@ -616,7 +703,6 @@ all = [ ] }, ] "#; - assert!(parse_manifest(manifest).is_err()); } @@ -636,20 +722,18 @@ state = "idle" contains = [{matchers}] "# ); - assert!(parse_manifest(&manifest).is_err()); } #[test] fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() { let content = "marker\nold\n\nmiddle\nmarker\nnew\n"; - assert_eq!( region( DetectionInput { screen: content, osc_title: "", - osc_progress: "", + osc_progress: "" }, "bottom_non_empty_lines(2)" ), @@ -660,13 +744,12 @@ fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() { #[test] fn top_non_empty_lines_uses_top_occurrence_for_repeated_text() { let content = "\nmarker\nold\n\nmiddle\nmarker\nnew\n"; - assert_eq!( region( DetectionInput { screen: content, osc_title: "", - osc_progress: "", + osc_progress: "" }, "top_non_empty_lines(2)" ), @@ -690,7 +773,7 @@ fn top_non_empty_lines_requires_a_canonical_positive_bounded_count() { #[test] fn top_non_empty_lines_requires_engine_three_when_declared() { let manifest = r#" -id = "grok" +id = "codex" version = "1" min_engine_version = 2 @@ -700,849 +783,5 @@ state = "working" region = " top_non_empty_lines(1) " contains = ["active"] "#; - assert!(parse_manifest(manifest).is_err()); } - -// --------------------------------------------------------------------------- -// OSC rule tests — exercise the new osc_title / osc_progress regions against -// the bundled Claude and Codex manifests. -// --------------------------------------------------------------------------- - -fn osc_explain( - agent: Agent, - screen: &str, - osc_title: &str, - osc_progress: &str, -) -> DetectionExplain { - explain_with_input( - agent, - DetectionInput { - screen, - osc_title, - osc_progress, - }, - ) -} - -#[test] -fn grok_osc_activity_does_not_depend_on_configurable_title_text() { - for (title, progress, expected_state, expected_rule) in [ - ( - "project · session · id", - "4;0;0", - AgentState::Idle, - "osc_progress_idle", - ), - ( - "project · session · id", - "", - AgentState::Idle, - "prompt_hints_idle", - ), - ("grok", "", AgentState::Idle, "osc_title_idle"), - ( - "⠋ - Waiting for response… - project", - "", - AgentState::Working, - "osc_title_working", - ), - ( - "project - ⠹ - session", - "4;0;0", - AgentState::Working, - "osc_title_working", - ), - ( - "project · session · id", - "4;1;-1", - AgentState::Working, - "osc_progress_working", - ), - ( - "grok", - "4;1;-1", - AgentState::Working, - "osc_progress_working", - ), - ( - "⚠ Action Required - project", - "4;1;-1", - AgentState::Blocked, - "osc_title_blocked", - ), - ] { - let result = osc_explain( - Agent::Grok, - "Shift+Tab:mode │ Ctrl+.:shortcuts\n", - title, - progress, - ); - assert_eq!( - result.state, expected_state, - "title={title}, progress={progress}" - ); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some(expected_rule), - "title={title}, progress={progress}", - ); - } -} - -#[test] -fn grok_visible_activity_outranks_idle_osc_signals() { - for (screen, expected_rule) in [ - ( - "⠴ Sleep for 8 … 1.9s 5.0s ⇣19.5k [↓][stop]\n", - "spinner_status_working", - ), - ( - "Shift+Tab:mode │ Ctrl+c:cancel │ Ctrl+.:shortcuts\n", - "esc_cancel_hints_working", - ), - ( - "Shift+Tab:mode │ Esc:cancel │ Ctrl+.:shortcuts\n", - "esc_cancel_hints_working", - ), - ("◎ 1 command still running\n", "background_status_working"), - ("○ 1 command still running\n", "background_status_working"), - ("◉ 1 command still running\n", "background_status_working"), - ( - "◎ 2 commands · 1 subagent still running · send a message to interrupt\n", - "background_status_working", - ), - ] { - for progress in ["", "4;0;0"] { - let result = osc_explain(Agent::Grok, screen, "grok", progress); - assert_eq!(result.state, AgentState::Working, "screen={screen}"); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some(expected_rule) - ); - } - let blocked = osc_explain(Agent::Grok, screen, "⚠ Action Required - grok", "4;1;-1"); - assert_eq!(blocked.state, AgentState::Blocked); - } -} - -#[test] -fn grok_background_activity_requires_a_live_nonzero_status_row() { - for screen in [ - "1 command still running\n", - "◎ 0 commands still running\n", - "Discussed: ◎ 1 command still running\n", - "◎ 1 command still running\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n", - "Worked for 3.9s\nShift+Tab:mode │ Ctrl+.:shortcuts\n", - ] { - assert_eq!( - osc_explain(Agent::Grok, screen, "project · session", "4;0;0").state, - AgentState::Idle - ); - } -} - -// --- Claude OSC rules --- - -#[test] -fn claude_idle_prompt_with_background_shell_is_idle() { - // Captured from Claude Code 2.1.251 after its foreground turn ended while - // a long-lived background shell remained active (issue #3414). - let screen = concat!( - "✻ Sautéed for 10s · 1 shell still running\n\n", - "──────────────────────────────────────────────────────── WINDOWS ─\n", - "❯\n", - "────────────────────────────────────────────────────────────────\n", - " ⏵⏵ auto mode on · 1 shell · ← for agents /rc\n", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("live_prompt_box") - ); - assert!(result.visible_idle); - assert!(!result.visible_working); -} - -#[test] -fn claude_background_shell_without_foreground_evidence_is_idle_fallback() { - let result = osc_explain( - Agent::Claude, - " ⏵⏵ auto mode on · 1 shell · ← for agents\n", - "", - "", - ); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!(result.matched_rule, None); - assert_eq!( - result.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); - assert!(!result.visible_working); -} - -#[test] -fn claude_live_turn_with_background_shell_remains_working() { - let screen = concat!( - "────────────────────────────────────────────────────────────────\n", - "❯\n", - "────────────────────────────────────────────────────────────────\n", - " ⏵⏵ auto mode on · 1 shell · esc to interrupt\n", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("live_turn_working") - ); - assert!(result.visible_working); -} - -#[test] -fn claude_blocker_with_background_shell_remains_blocked() { - let screen = concat!( - "do you want to proceed?\n", - "bash command: rm -rf /tmp/test\n", - "❯ 1. Yes\n", - " 2. No\n\n", - "Esc to cancel · Tab to amend · ctrl+e to explain\n", - " ⏵⏵ auto mode on · 1 shell · ← for agents\n", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("bash_permission_prompt") - ); - assert!(result.visible_blocker); - assert!(!result.visible_working); -} - -#[test] -fn claude_bash_prompt_with_dont_ask_again_option_matches_bash_rule() { - // Captured from a Bash approval prompt at its resting cursor position. The - // "don't ask again" choice pushes No to option 3, so the only cursor-free - // option line is one bash_permission_prompt used not to cover, which let - // the narrower generic_permission_prompt claim the prompt instead (#2650). - let screen = concat!( - "──────────────────────────────────────────────────────────────── -", - " Bash command - -", - " curl -sS -o /tmp/probe.html https://example.com -", - " Download example.com to /tmp/probe.html - -", - " This command requires approval - -", - " Do you want to proceed? -", - " ❯ 1. Yes -", - " 2. Yes, and don't ask again for: curl * -", - " 3. No - -", - " Esc to cancel · Tab to amend · ctrl+e to explain -", - ); - let result = osc_explain(Agent::Claude, screen, "", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("bash_permission_prompt") - ); - assert!(result.visible_blocker); -} - -#[test] -fn claude_permission_prompt_matches_at_every_cursor_position() { - // The selected option carries "❯", so no option branch may assume its line - // is cursor-free. Walk the cursor across both option layouts. - let layouts: [&[&str]; 2] = [ - &[" ❯ 1. Yes", " 2. No"], - &[ - " ❯ 1. Yes", - " 2. Yes, and don't ask again for: curl *", - " 3. No", - ], - ]; - - for layout in layouts { - for selected in 0..layout.len() { - let options: Vec<String> = layout - .iter() - .enumerate() - .map(|(index, line)| { - let bare = line.trim_start().trim_start_matches('❯').trim_start(); - if index == selected { - format!(" ❯ {bare}") - } else { - format!(" {bare}") - } - }) - .collect(); - let screen = format!( - concat!( - "──────────────────────────────────────────────────────────────── -", - " Bash command - -", - " curl -sS https://example.com - -", - " Do you want to proceed? -", - "{} - -", - " Esc to cancel · Tab to amend · ctrl+e to explain -", - ), - options.join("\n"), - ); - let result = osc_explain(Agent::Claude, &screen, "", ""); - - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("bash_permission_prompt"), - "{options:?} selected={selected}" - ); - assert_eq!(result.state, AgentState::Blocked, "selected={selected}"); - assert!(result.visible_blocker, "selected={selected}"); - } - } -} - -#[test] -fn claude_osc_title_braille_prefix_is_working() { - // "⠂" is U+2802, in the braille block U+2800-U+28FF - let result = osc_explain(Agent::Claude, "", "⠂ project", ""); - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); - assert!(result.visible_working); -} - -#[test] -fn claude_osc_title_half_circle_frames_are_working() { - for frame in ['◐', '◓', '◑', '◒'] { - let title = format!("{frame} Initial conversation with Claude"); - let result = osc_explain(Agent::Claude, "", &title, ""); - assert_eq!(result.state, AgentState::Working, "frame {frame}"); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("osc_title_working"), - "frame {frame}" - ); - assert!(result.visible_working, "frame {frame}"); - } -} - -#[test] -fn claude_osc_title_static_prefix_is_idle() { - // "✳" is U+2733, static prefix when Claude is not working - let result = osc_explain(Agent::Claude, "", "✳ Claude Code", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); -} - -#[test] -fn claude_osc_progress_4_3_alone_does_not_force_working() { - // Claude leaves progress stuck at 4;3 while waiting for permission, so - // 4;3 must not be a working signal on its own. With no other evidence it - // falls back to idle; blocked screen rules can win when present. - let result = osc_explain(Agent::Claude, "", "", "4;3;"); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); - assert!(!result.visible_working); -} - -#[test] -fn claude_blocker_screen_outranks_stale_osc_progress() { - // Regression: progress 4;3 persists during permission prompts. The - // blocked form on screen must win because no rule treats 4;3 as working. - let blocker_screen = - "──────────\n 1. Yes\n 2. No\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n"; - let result = osc_explain(Agent::Claude, blocker_screen, "✳ Task title", "4;3;"); - assert_eq!(result.state, AgentState::Blocked); - assert!(result.visible_blocker); -} - -#[test] -fn claude_osc_progress_4_0_is_idle() { - let result = osc_explain(Agent::Claude, "", "", "4;0;"); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_progress_idle") - ); -} - -#[test] -fn claude_blocker_screen_outranks_osc_idle_title() { - // When the OSC title shows ✳ (idle) but the screen has a bash permission - // prompt, the blocked rule at priority 850 beats osc_title_idle at 250. - let blocker_screen = "do you want to proceed?\n\ - bash command: rm -rf /tmp/test\n\ - ❯ 1. Yes\n 2. No\n\n\ - Esc to cancel · Tab to amend · ctrl+e to explain\n"; - let result = osc_explain(Agent::Claude, blocker_screen, "✳ Claude Code", ""); - assert_eq!(result.state, AgentState::Blocked); - assert!(result.visible_blocker); -} - -#[test] -fn claude_mcp_elicitation_is_blocked() { - // Regression for issue #3283: an MCP elicitation dialog has Accept/Decline - // controls and an "Esc to cancel" footer but no Enter hint, so no blocked - // rule matched and the static OSC title reported idle. - // Live capture uses curly quotes around the server name; the issue report - // transcribed straight quotes. Both must classify as blocked. - for screen in [ - "MCP server \u{201c}my-server\u{201d} requests your input\n\nGrant temporary access to the demo gateway for 15 minutes?\n\n\u{276f} Accept Decline\n\nEsc to cancel \u{b7} \u{2191}/\u{2193} to navigate\n", - "MCP server \"my-server\" requests your input\n\nserver-supplied message\n\n\u{276f} Accept Decline\n\nEsc to cancel \u{b7} \u{2191}/\u{2193} to navigate\n", - ] { - let result = with_manifest_dirs("claude-mcp-elicitation", || { - osc_explain(Agent::Claude, screen, "\u{2733} Claude Code", "") - }); - assert_eq!(result.state, AgentState::Blocked, "{result:#?}"); - assert!(result.visible_blocker, "{result:#?}"); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("mcp_elicitation_prompt"), - "{result:#?}" - ); - } -} - -#[test] -fn claude_empty_osc_empty_screen_is_idle_fallback() { - // No OSC data, no matching screen rule → fallback idle (unchanged V3 behavior) - let result = osc_explain(Agent::Claude, "", "", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); - assert!(!result.visible_idle); -} - -// --- Codex OSC rules --- - -#[test] -fn codex_osc_title_braille_spinner_is_working() { - // "⠋" is U+280B, in the braille block - let result = osc_explain(Agent::Codex, "", "⠋ llm-proxy", ""); - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_osc_title_action_required_is_blocked() { - let result = osc_explain(Agent::Codex, "", "[ . ] Action Required | llm-proxy", ""); - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_blocked") - ); - assert!(result.visible_blocker); -} - -#[test] -fn codex_osc_title_plain_is_idle() { - let result = osc_explain(Agent::Codex, "", "llm-proxy", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); -} - -#[test] -fn codex_trust_directory_requires_live_top_region() { - let screen = "> You are in C:\\Users\\user\\project\n\n\ - Do you trust the contents of this\n\ - directory? Working with untrusted\n\ - contents comes with higher risk of\n\ - prompt injection. Trusting the\n\ - directory allows project-local config,\n\ - hooks, and exec policies to load.\n\n\ - › 1. Yes, continue\n\ - 2. No, quit\n\n\ - Press enter to continue\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("trust_directory") - ); - assert!(result.visible_blocker); - - let transcript = "› > You are in C:\\Users\\user\\project\n\n\ - Do you trust the contents of this\n\ - directory? Working with untrusted contents comes with higher risk.\n"; - let result = osc_explain(Agent::Codex, transcript, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_ne!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("trust_directory") - ); - assert!(!result.visible_blocker); -} - -#[test] -fn codex_startup_update_requires_complete_live_chooser() { - let chooser = "Update available! 0.153.0 -> 9.8.7\n\ - Run bun add -g @openai/codex to update.\n\n\ - › 1. Update now\n\ - 2. Skip until next version\n\n\ - Press enter to continue \n"; - let wrapped = "✨ Update available! 0.153.0\n\n\ - Release notes: https://example\n\n\ - › 1. Update now (runs `npm\n\ - install -g\n\ - @openai/codex`)\n\ - 2. Skip\n\ - 3. Skip until next\n\ - version\n\n\ - Press enter to continue\n"; - - for screen in [chooser, wrapped] { - let result = osc_explain(Agent::Codex, screen, "project", ""); - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("startup_update") - ); - assert!(result.visible_blocker); - } - - for screen in [ - chooser.replace("Update now", "Install"), - format!("{wrapped}\n› Ask Codex to do anything\n"), - ] { - let result = osc_explain(Agent::Codex, &screen, "project", ""); - assert_eq!(result.state, AgentState::Idle); - assert_ne!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("startup_update") - ); - assert!(!result.visible_blocker); - } -} - -#[test] -fn codex_background_terminal_screen_does_not_override_osc_idle() { - // Background terminal tasks can be long-lived helpers such as dev servers. - // They should not make Codex look busy once the foreground turn is idle. - let screen = "background terminal running · /ps to view · /stop to close\n"; - let result = osc_explain(Agent::Codex, screen, "llm-proxy", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); -} - -#[test] -fn codex_screen_working_fallback_handles_static_osc_title() { - let screen = "• I’ll run it and wait for completion.\n\n\ - ◦ Working (1m 16s • esc to interrupt) · 1 background…\n\n\ - › Use /skills to list available skills\n\n\ - gpt-5.6-sol default · /work\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("screen_working_fallback") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_screen_working_fallback_handles_activity_labels_and_queued_inputs() { - for prefix in ["", "• ", "◦ "] { - for label in ["Working", "Fixing bug in queue region"] { - for queue in [ - "", - "\n• Queued follow-up inputs\n ↳ Follow up after this turn\n alt + ↑ edit last queued message\n", - "\n• Messages to be submitted after next tool call\n (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted after next tool call (press esc to interrupt\n and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted after next\n tool call (press esc to interrupt and\n send immediately)\n ↳ Keep waiting until the sleep finishes.\n", - "\n• Messages to be submitted at end of turn\n ↳ Follow up after this turn\n", - "\n• Messages to be submitted after next tool call\n (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n\n• Queued follow-up inputs\n ↳ After this turn reply ok.\n alt + ↑ edit last queued message\n", - ] { - let screen = format!( - "{prefix}{label} (1m 16s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close\n\ - {queue}\n› Ask Codex to do anything\n\n model · /work\n" - ); - let result = osc_explain(Agent::Codex, &screen, "project", ""); - - assert_eq!(result.state, AgentState::Working, "{screen}"); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("screen_working_fallback"), - "{screen}" - ); - assert!(result.visible_working); - } - } - } -} - -#[test] -fn codex_screen_working_fallback_uses_latest_activity_after_interruption() { - let screen = "■ Conversation interrupted\n\n\ - › Try again\n\n\ - Working (4s • esc to interrupt)\n\n\ - › Ask Codex to do anything\n\n model · /work\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Working); - assert!(result.visible_working); -} - -#[test] -fn codex_osc_working_remains_preferred_over_screen_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\n\ - › Use /skills to list available skills\n\n\ - gpt-5.6-sol default · /work\n"; - let result = osc_explain(Agent::Codex, screen, "⠸ project", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_screen_blocker_outranks_working_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\ - › 1. Yes, proceed\n\ - Press enter to confirm or esc to cancel\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("live_strong_blocker") - ); - assert!(result.visible_blocker); - assert!(!result.visible_working); -} - -#[test] -fn codex_weak_blocker_without_current_prompt_is_blocked() { - let result = osc_explain( - Agent::Codex, - "do you want to continue? [y/n]\n", - "project", - "", - ); - - assert_eq!(result.state, AgentState::Blocked); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("weak_blocker") - ); -} - -#[test] -fn codex_current_prompt_keeps_weak_text_from_overriding_working_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\ - do you want to continue? [y/n]\n\ - › Use /skills to list available skills\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("screen_working_fallback") - ); - assert!(result.visible_working); -} - -#[test] -fn codex_weak_blocker_ignores_finished_response_above_current_prompt() { - let screen = "• The `wt rm` transcript now shows [y/N] / esc, matching the real prompt.\n\n\ - ─ Worked for 4m 59s ─\n\n\ - › Ask Codex to do anything\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); -} - -#[test] -fn codex_weak_blocker_ignores_wrapped_current_prompt_text() { - let screen = "› Explain why this prompt wraps before quoting the confirmation text\n\ - [y/N] / esc and whether the docs should include it\n\n\ - gpt-5.6-sol default · /work\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); -} - -#[test] -fn codex_sparkle_prompt_preserves_live_states() { - for marker in ["› ", "›⠁", "›⠂", "›⠄", "›⠈", "›⠐", "›⠠", "›⡀", "›⢀"] - { - let screen = format!("Do you want to proceed? [y/n]\n{marker}unsent draft\n"); - let result = osc_explain(Agent::Codex, &screen, "project | Ready", ""); - assert_eq!(result.state, AgentState::Idle, "{marker}"); - - let working = format!( - "Do you want to proceed? [y/n]\n• Working (4s • esc to interrupt)\n{marker}draft\n" - ); - let result = osc_explain(Agent::Codex, &working, "project", ""); - assert_eq!(result.state, AgentState::Working, "{marker}"); - - let approval = format!("{screen}Press enter to confirm or esc to cancel\n"); - let result = osc_explain(Agent::Codex, &approval, "project", ""); - assert_eq!(result.state, AgentState::Blocked, "{marker}"); - assert!(result.visible_blocker); - - for response_marker in ['•', '■', '✗', '✓'] { - let response = format!("{screen}{response_marker} Do you want to proceed? [y/n]\n"); - let result = osc_explain(Agent::Codex, &response, "project", ""); - assert_eq!( - result.state, - AgentState::Blocked, - "{marker} {response_marker}" - ); - } - } -} - -#[test] -fn codex_weak_blocker_does_not_ignore_arbitrary_prompt_suffixes() { - for line in ["›text", "›⠋draft", "›⠀draft", " ›⠁draft", "quoted ›⠁draft"] { - let screen = format!("Do you want to proceed? [y/n]\n{line}\n"); - let result = osc_explain(Agent::Codex, &screen, "project", ""); - assert_eq!(result.state, AgentState::Blocked, "{line}"); - } -} - -#[test] -fn codex_transcript_viewer_outranks_working_fallback() { - let screen = "• Working (4s • esc to interrupt)\n\ - › transcript\n\ - ↑/↓ to scroll · pgup/pgdn to move · home/end to jump · q to quit · esc to edit prev\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Unknown); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("transcript_viewer") - ); - assert!(result.skip_state_update); - assert!(!result.visible_working); -} - -#[test] -fn codex_screen_working_fallback_ignores_stale_and_prompt_text() { - let screens = [ - "◦ Working (1m 16s • esc to interrupt)\n\ - ■ Conversation interrupted\n\ - › Use /skills to list available skills\n\ - gpt-5.6-sol default · /work\n", - "› Explain the text ◦ Working (1m 16s • esc to interrupt)\n\ - gpt-5.6-sol default · /work\n", - " ◦ Working (1m 16s • esc to interrupt)\n\ - › Use /skills to list available skills\n\ - gpt-5.6-sol default · /work\n", - "Working (1m 16s • esc to interrupt)\n\ - • Finished the task\n\ - › Ask Codex to do anything\n", - "Working (1m 16s • esc to interrupt)\n\ - ■ Conversation interrupted\n\ - › Ask Codex to do anything\n", - "Working (1m 16s • esc to interrupt)\n\ - ─ Worked for 1m 16s ─\n\ - › Ask Codex to do anything\n", - "Working (1m 16s • esc to interrupt)\n•\nMessages to be submitted after next tool call\n› Ask Codex to do anything\n", - "› Explain this status:\n Working (1m 16s • esc to interrupt)\n", - "• Example (press esc to interrupt)\n\ - › Ask Codex to do anything\n", - ]; - - for screen in screens { - let result = osc_explain(Agent::Codex, screen, "project", ""); - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); - assert!(!result.visible_working); - } -} - -#[test] -fn codex_screen_working_fallback_ignores_interrupted_short_terminal() { - let screen = "◦ Working (1m 16s • esc to interrupt)\n\ - ■ Conversation interrupted\n\ - ›\n"; - let result = osc_explain(Agent::Codex, screen, "project", ""); - - assert_eq!(result.state, AgentState::Idle); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_idle") - ); - assert!(result.visible_idle); - assert!(!result.visible_working); -} - -#[test] -fn codex_osc_working_beats_weak_blocker_screen() { - // A stale [y/n] on screen triggers weak_blocker at priority 600, but an - // active braille spinner in the OSC title is priority 1050 — OSC wins. - let screen = "do you want to continue? [y/n]\n"; - let result = osc_explain(Agent::Codex, screen, "⠋ llm-proxy", ""); - assert_eq!(result.state, AgentState::Working); - assert_eq!( - result.matched_rule.as_ref().map(|r| r.id.as_str()), - Some("osc_title_working") - ); -} diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 0c773f0e..4ed32344 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -893,14 +893,6 @@ mod tests { std::env::temp_dir().join(unique) } - #[test] - fn moved_agent_detection_routes_through_production_dispatch() { - let detection = detect_agent(Some(Agent::Pi), "Working..."); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - // ---- Agent identification ---- #[test] @@ -1278,101 +1270,6 @@ mod tests { assert_eq!(identify_agent_in_job(&source_checkout), None); } - #[test] - fn letta_manifest_detects_observed_working_and_idle_chrome() { - let empty = manifest::explain(Agent::Letta, ""); - assert_eq!(empty.state, AgentState::Unknown); - assert_eq!( - empty.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("no_live_state_evidence") - ); - for screen in [ - "✻ Thinking…\nTutor is reflecting… (esc to interrupt · 2m 3s)", - "My Tutor is thinking about thinking… (esc to interrupt · 3s)", - "Tutor is calibrating… (interrupting)", - "• Run Compile the integration\n└ Running... (1s)", - ] { - assert_eq!( - detect_state(Some(Agent::Letta), screen), - AgentState::Working - ); - } - assert_eq!( - detect_state( - Some(Agent::Letta), - "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · No model selected" - ), - AgentState::Idle - ); - assert_eq!( - detect_state( - Some(Agent::Letta), - "────────────────\n› explain this code\n────────────────\nTutor · No model selected" - ), - AgentState::Unknown - ); - let selector = manifest::explain( - Agent::Letta, - "8 pinned agents available.\n\n> Resume Bob (pinned)\n View all 8 profiles\n Create a new agent (--new)\n\n ↑↓ navigate · Enter select · Esc exit", - ); - assert_eq!(selector.state, AgentState::Unknown); - assert_eq!( - selector.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("profile_selector") - ); - } - - #[test] - fn letta_manifest_uses_osc_activity_and_approval_signals() { - let idle_screen = - "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · GPT-5.5"; - - for title in ["⠋ Tutor", "project | ⠏ Tutor"] { - let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - for title in [ - "[ ! ] Action Required | Tutor", - "[ . ] Action Required | Tutor", - ] { - let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, "Tutor", "4;3;0"); - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn letta_manifest_detects_observed_command_approval() { - let approval = r#"✻ Thinking… - -──────────────────────────────────────────────────────────────── -Run this command? - - $ rm -f /var/tmp/herdr-blocked-capture-never-created - -❯ 1. Yes - 2. No, and tell Letta Code what to do differently - -Enter to select · Esc to cancel"#; - assert_eq!( - detect_state(Some(Agent::Letta), approval), - AgentState::Blocked - ); - assert_eq!( - detect_state( - Some(Agent::Letta), - "The user asked: Run this command?\n›\n────\nTutor · No model selected" - ), - AgentState::Idle - ); - } - #[test] fn identify_agent_in_job_detects_windows_cursor_install() { let job = crate::platform::ForegroundJob { From 68dd6edaa20a0f75f9ed4cf375492ae5cfd2c336 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Fri, 18 Sep 2026 15:18:23 +0200 Subject: [PATCH 16/99] test(windows): qualify remote clipboard image paste (#4324) * test(windows): qualify remote clipboard image paste refs #4314 * test(windows): verify staged clipboard image contents refs #4314 * test(windows): safely clean failed clipboard leases refs #4314 --- scripts/test_windows_input.ps1 | 33 ++++++++++----- scripts/test_windows_input.py | 12 ++++++ scripts/windows_input/Bootstrap.ps1 | 8 +--- scripts/windows_input/Common.ps1 | 5 ++- scripts/windows_input/Native.cs | 63 +++++++++++++++++++++++++---- scripts/windows_input/README.md | 14 ++++++- scripts/windows_input/report.py | 38 ++++++++++++++--- 7 files changed, 141 insertions(+), 32 deletions(-) diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index 28839a07..7396bffa 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -11,7 +11,7 @@ param( [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')][string[]] $Paths = @('direct', 'herdr'), + [ValidateSet('direct', 'herdr', 'herdr-remote')][string[]] $Paths = @('direct', 'herdr'), [string[]] $Cases, [int[]] $Widths = @(80, 119, 120, 121, 132, 160, 240), [int[]] $Heights = @(24, 50), @@ -172,7 +172,7 @@ try { $cursorPosition = $null; $mouseReporting = $false Update-GauntletLease $root try { - if ($path -eq 'herdr') { + 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() @@ -267,11 +267,11 @@ try { $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') -and [HerdrInputGauntlet.Desktop]::CountClipboardFormats() -ne 0) { + 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 -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) { @(Get-Content -LiteralPath $plan.input_trace).Count } else { 0 } + $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) @@ -282,6 +282,10 @@ try { } 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)) @@ -329,9 +333,18 @@ try { 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 -eq 'herdr' -and (Test-Path -LiteralPath $plan.input_trace)) { + 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" @@ -339,7 +352,7 @@ try { if ($captureTrace.Contains('transport=win32-serialized')) { $row.input_transport = 'win32-serialized' } } if ($null -ne $clipboardSequence) { - if (-not [HerdrInputGauntlet.Desktop]::ClearOwnedClipboard($clipboardSequence)) { throw 'Could not clear test-owned clipboard' } + 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 } @@ -361,7 +374,7 @@ try { 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($clipboardSequence)) { $document.cleanup_errors += 'Could not clear test-owned clipboard' } + 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 { @@ -370,7 +383,7 @@ try { } catch { $document.cleanup_errors += $_.Exception.Message } } # Normal detach only while still owning foreground focus; never type into another window. - if ($injectionAuthorized -and $path -eq 'herdr' -and [HerdrInputGauntlet.Desktop]::IsOwned($window, $nonce, $windowPid) -and [HerdrInputGauntlet.Desktop]::GetForegroundWindow() -eq $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 @@ -398,9 +411,7 @@ try { $document.cleanup_errors += "$nonce server required forced cleanup" if (-not $server.WaitForExit(5000)) { $document.cleanup_errors += "$nonce server remained active after forced cleanup" } } - if (-not (Test-Path (Join-Path $work 'bootstrap-exit.json'))) { - try { $null = Invoke-GauntletProcess $exe @('session', 'delete', $nonce) $plan } catch { $document.cleanup_errors += $_.Exception.Message } - } + try { $null = Invoke-GauntletProcess $exe @('session', 'delete', $nonce) $plan } catch { $document.cleanup_errors += $_.Exception.Message } $server.Dispose() } if ($null -ne $launcher) { $launcher.Dispose() } diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py index f8a70eee..38868843 100644 --- a/scripts/test_windows_input.py +++ b/scripts/test_windows_input.py @@ -89,6 +89,18 @@ def test_paste_requires_one_envelope_and_complete_unicode_payload(self): (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(), + "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_mouse_interleave_requires_ordered_motion_and_paste(self): case = self.cases["mouse-interleave"] motion = b"\x1b[<35;10;5M" diff --git a/scripts/windows_input/Bootstrap.ps1 b/scripts/windows_input/Bootstrap.ps1 index 4f6884fb..6008c64f 100644 --- a/scripts/windows_input/Bootstrap.ps1 +++ b/scripts/windows_input/Bootstrap.ps1 @@ -14,7 +14,7 @@ Add-Type -Path "$PSScriptRoot/Native.cs" $before = [HerdrInputGauntlet.ConsoleProbe]::Geometry() $child = $null try { - if ($plan.path -eq 'herdr') { + 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 @@ -39,12 +39,6 @@ try { } $child.Dispose() } - if ($plan.path -eq 'herdr') { - foreach ($verb in @('stop', 'delete')) { - try { $null = Invoke-GauntletProcess $plan.exe @('session', $verb, $plan.session) $plan } - catch { $cleanup += $_.ToString() } - } - } 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 index 537b66ae..d944e87b 100644 --- a/scripts/windows_input/Common.ps1 +++ b/scripts/windows_input/Common.ps1 @@ -40,7 +40,10 @@ function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) { $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 -eq 'herdr') { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + if ($Plan.path -in @('herdr', 'herdr-remote')) { $info.Environment['HERDR_WINDOWS_INPUT_TRACE_FILE'] = $Plan.input_trace } + if ($Plan.path -eq 'herdr-remote') { + $info.Environment['HERDR_REMOTE_KEYBINDINGS'] = 'local' + } } $info.RedirectStandardOutput = $Capture.IsPresent $info.RedirectStandardError = $Capture.IsPresent diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs index 5f941f1a..42b387b1 100644 --- a/scripts/windows_input/Native.cs +++ b/scripts/windows_input/Native.cs @@ -115,6 +115,7 @@ public static void AssertNotElevated(int pid) { [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); @@ -142,6 +143,7 @@ public static int ActivateApplication(string appUserModelId,string[] arguments) } 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 { @@ -155,17 +157,64 @@ public static uint SetEmptyClipboard(IntPtr owner,string text) { 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. - // Reopen before adopting it so another writer cannot become our lease. - if(!OpenClipboard(owner)) throw new Exception("Clipboard busy; cannot establish test ownership"); + uint sequence=GetClipboardSequenceNumber(); try { - if(owner==IntPtr.Zero || GetClipboardOwner()!=owner) throw new Exception("Clipboard ownership changed; refusing cleanup lease"); - return GetClipboardSequenceNumber(); - } finally { CloseClipboard(); } + 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(uint sequence) { + public static bool ClearOwnedClipboard(IntPtr owner,uint sequence) { + if(owner==IntPtr.Zero) return false; if(!OpenClipboard(IntPtr.Zero)) return false; - try { return GetClipboardSequenceNumber()!=sequence || EmptyClipboard(); } + 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(); } diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index 36caf3f6..5f8f463f 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -87,6 +87,16 @@ arrays normally: -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 ` + -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. @@ -120,6 +130,8 @@ Every run needs a **new** output directory. By default it is 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. - A full case pass at an observed 120×30 host size; 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 @@ -138,7 +150,7 @@ 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; image/file clipboard integrations; 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 diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index 7c15c149..d6c20803 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -78,6 +78,12 @@ def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): ("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", @@ -115,7 +121,6 @@ def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): ("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."), - ("clipboard-nontext", "Qualify supported image/file clipboard integrations separately; do not infer from text paste."), ]: cases.append(dict(id=name, kind="qualification", prompt=prompt, expected={})) return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases) @@ -132,6 +137,8 @@ def verdict(case, mode, evidence): 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): @@ -204,6 +211,25 @@ def geometry(name): raw = bytes.fromhex(evidence["hex"]) except (KeyError, ValueError, TypeError): return "inconclusive", "Missing or malformed raw bytes" + if expected.get("clipboard_image"): + if evidence.get("path") == "direct": + return (("pass", "Terminal emitted an empty bracketed paste for image-only clipboard") + if raw == b"\x1b[200~\x1b[201~" else + ("fail", "Terminal did not emit 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" + 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)" @@ -332,6 +358,7 @@ def qualification_matrix(result): ("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), @@ -378,7 +405,8 @@ def case_passed(case_id): table = [] for name, case_ids, width in groups: 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"} - table.append((name, cell(case_ids, width, "herdr", herdr_modes), + herdr_path = "herdr-remote" if name == "Remote clipboard image" else "herdr" + 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 @@ -386,10 +414,10 @@ def case_passed(case_id): def herdr_protocol_label(result): 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") == "herdr" and run.get("nonce")} + if run.get("path") in {"herdr", "herdr-remote"} and run.get("nonce")} proven = {(row.get("host"), row.get("path"), row.get("mode"), row.get("nonce")) for row in result.get("observations", []) - if row.get("path") == "herdr" and row.get("input_reader") == "windows-console" + 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 "Win32 (Herdr)*" if runs and runs <= proven else "Herdr default (UNKNOWN)*" @@ -418,7 +446,7 @@ def main(): 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") == "herdr" for row in result["observations"]) + 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; " From a19a595c55d575fbe8f899e1fdf5d62f04c3a4ac Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Fri, 18 Sep 2026 17:02:09 +0300 Subject: [PATCH 17/99] perf: enable ssh compression for remote connections (#4340) Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- .../src/content/docs/connecting-machines.mdx | 2 ++ src/remote/attach.rs | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 0e553552..3f7bcfe6 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -7,6 +7,8 @@ Keep your local work and remote agents in one Herdr window. Save an SSH machine Each machine keeps its own Herdr server, sessions, and running processes. A lost connection to one machine does not disconnect the others. +Herdr requests SSH compression to reduce screen-update traffic on limited connections. This works with older compatible Herdr servers and does not change how typing or other input is delivered. Herdr's `-C` option overrides `Compression no` in your SSH config. If SSH reuses an existing connection through an external ControlMaster, that connection keeps its original compression setting. + ## Before you connect You need normal SSH access to the remote machine. Verify it first: diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 53023b83..8e0950b9 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -1045,6 +1045,8 @@ fn apply_noninteractive_ssh_options(command: &mut Command) { } fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshOptions>) { + // Compress the first connection too: multiplexed bridges inherit the master's transport. + command.arg("-C"); let Some(options) = options else { return; }; @@ -1062,6 +1064,7 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO } fn apply_managed_scp_options(command: &mut Command, options: Option<&ManagedSshOptions>) { + command.arg("-C"); let Some(options) = options else { return; }; @@ -3623,6 +3626,7 @@ mod tests { assert_eq!( args, vec![ + "-C".to_string(), "-F".to_string(), config_path.to_string_lossy().into_owned(), "-S".to_string(), @@ -3644,6 +3648,7 @@ mod tests { assert_eq!( scp_args, vec![ + "-C".to_string(), "-F".to_string(), config_path.to_string_lossy().into_owned(), "-o".to_string(), @@ -3690,6 +3695,7 @@ mod tests { assert_eq!( args, vec![ + "-C".to_string(), "-F".to_string(), config_path.to_string_lossy().into_owned(), "-T".to_string(), @@ -3703,7 +3709,11 @@ mod tests { .collect::<Vec<_>>(); assert_eq!( scp_args, - vec!["-F".to_string(), config_path.to_string_lossy().into_owned(),] + vec![ + "-C".to_string(), + "-F".to_string(), + config_path.to_string_lossy().into_owned(), + ] ); } @@ -3747,6 +3757,7 @@ mod tests { .map(|arg| arg.to_string_lossy().into_owned()) .collect::<Vec<_>>(); for required in [ + "-C", "BatchMode=yes", "NumberOfPasswordPrompts=0", "StrictHostKeyChecking=yes", @@ -3842,7 +3853,7 @@ mod tests { } #[test] - fn remote_ssh_command_is_plain_without_managed_config() { + fn remote_ssh_commands_compress_without_managed_config() { let ssh = RemoteSsh { target: "example".to_string(), session_name: crate::session::DEFAULT_SESSION_NAME.into(), @@ -3856,8 +3867,8 @@ mod tests { .map(|arg| arg.to_string_lossy().into_owned()) .collect::<Vec<_>>(); - assert_eq!(args, vec!["-T".to_string(), "example".to_string()]); - assert!(ssh.scp_command().get_args().next().is_none()); + assert_eq!(args, vec!["-C", "-T", "example"]); + assert_eq!(ssh.scp_command().get_args().collect::<Vec<_>>(), vec!["-C"]); } #[test] From 241063f7ffb9583ba9b3438b32a82fcec3cf32a3 Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Fri, 18 Sep 2026 17:41:56 +0300 Subject: [PATCH 18/99] fix: preserve request ids in socket error responses (#4353) * fix: preserve request ids in socket error responses refs #4344 * docs: clarify socket error response limits refs #4344 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> --- .../website/src/content/docs/socket-api.mdx | 8 ++ src/api/server.rs | 110 +++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 5bd933dd..84068cea 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -670,6 +670,11 @@ A successful response includes the same `id`: {"id":"req_1","result":{"type":"pong"}} ``` +For request lines that reach JSON decoding, error responses also echo the request's +`id`, including when the method or parameters are invalid. If the JSON is invalid +or has no unambiguous top-level string `id`, the error response uses an empty `id`. +Transport errors, such as invalid UTF-8, can close the connection without a response. + Event subscriptions keep the connection open after the initial response. ## Socket paths @@ -814,6 +819,9 @@ Subscribe to events when you need a long-lived stream: ``` The first response acknowledges the subscription. Later lines are pushed events. +All entries must be valid: if any referenced pane does not exist, Herdr rejects +the entire request with an error and closes the connection. Refresh the pane list +and retry rather than assuming the other entries were subscribed. Lifecycle subscriptions start when the request is accepted and do not replay events retained before that point. diff --git a/src/api/server.rs b/src/api/server.rs index ea7597e7..55571d75 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -177,10 +177,23 @@ fn handle_connection_with_stop( let request = match serde_json::from_str::<Request>(line) { Ok(request) => request, Err(request_error) => { + // 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::<RequestId>(line) + .map(|request| request.id) + .unwrap_or_default() + } else { + String::new() + }; write_json_line_allow_disconnect( &mut stream, &ErrorResponse { - id: String::new(), + id, error: ErrorBody { code: "invalid_request".into(), message: format!("invalid request: {request_error}"), @@ -718,7 +731,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(()); @@ -957,7 +971,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}; @@ -1437,6 +1451,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::<ApiRequestMessage>(); + 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::<ApiRequestMessage>(); @@ -1461,6 +1564,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); From 028780cebccc135d67a9ff97fbb0b11a150608de Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Fri, 18 Sep 2026 21:50:26 +0300 Subject: [PATCH 19/99] fix: reveal selected agent when cycling the sidebar (#4355) * fix: reveal selected agent when cycling the sidebar * fix: reveal agent after endpoint activation --- src/client/shell/endpoint_agents.rs | 35 ++++++ src/client/shell/endpoint_navigation.rs | 18 ++- src/client/shell/endpoints.rs | 7 ++ src/client/shell/state.rs | 2 + src/client/shell/tests/endpoints.rs | 148 ++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 2 deletions(-) diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs index 3cc106b3..b56b41fa 100644 --- a/src/client/shell/endpoint_agents.rs +++ b/src/client/shell/endpoint_agents.rs @@ -87,6 +87,41 @@ pub(super) fn render_expanded( ); } +impl ClientShellState { + pub(super) fn reveal_endpoint_agent( + &mut self, + endpoint_id: &ClientEndpointId, + pane_id: &str, + body_height: u16, + ) { + if body_height == 0 { + return; + } + let rows = agent_rows(&self.endpoints, &self.active_endpoint_id, &self.config); + let Some(target) = rows + .iter() + .position(|row| &row.endpoint_id == endpoint_id && row.agent.pane_id == pane_id) + else { + return; + }; + let heights = rows + .iter() + .map(|row| row.agent.rows.len().max(1).min(u16::MAX as usize) as u16) + .collect::<Vec<_>>(); + let mut gaps = vec![self.config.agents.row_gap; rows.len()]; + if let Some(last) = gaps.last_mut() { + *last = 0; + } + self.agent_scroll = super::scroll::list_scroll_start_to_reveal( + &heights, + &gaps, + body_height, + self.agent_scroll, + target, + ); + } +} + struct EndpointAgentRow { endpoint_id: ClientEndpointId, machine_label: String, diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index 6cb8c2c4..c15fd434 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -203,11 +203,23 @@ impl ClientShellState { _ => unreachable!("endpoint agent navigation"), }; let target = &agents[next]; - self.focus_or_activate( + if self.focus_or_activate( target.endpoint_id.clone(), ClientEndpointFocusTarget::Pane(target.pane_id.clone()), outcome, - ); + ) { + if target.endpoint_id == self.active_endpoint_id { + self.reveal_endpoint_agent( + &target.endpoint_id, + &target.pane_id, + self.hits.agent_body.height, + ); + } else { + self.pending_agent_reveal = + Some((target.endpoint_id.clone(), target.pane_id.clone())); + } + outcome.repaint = true; + } return true; } false @@ -218,6 +230,7 @@ impl ClientShellState { endpoint_id: ClientEndpointId, outcome: &mut ClientShellInput, ) -> bool { + self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); if !online && !endpoint_id.is_local() { let label = self.endpoint_label(&endpoint_id).to_owned(); @@ -242,6 +255,7 @@ impl ClientShellState { target: ClientEndpointFocusTarget, outcome: &mut ClientShellInput, ) -> bool { + self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); if !online && !endpoint_id.is_local() { let label = self.endpoint_label(&endpoint_id).to_owned(); diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index c27358e5..3d1d419b 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -195,6 +195,10 @@ impl ClientShellState { } pub(crate) fn activate_endpoint_projection(&mut self, endpoint_id: &ClientEndpointId) -> bool { + let pending_agent_reveal = self + .pending_agent_reveal + .take_if(|(target_endpoint, _)| target_endpoint == endpoint_id); + let agent_body_height = self.hits.agent_body.height; let Some(endpoint) = self .endpoints .iter() @@ -221,6 +225,9 @@ impl ClientShellState { // The aggregate agent list belongs to the client, not one endpoint. self.agent_scroll = agent_scroll; } + if let Some((_, pane_id)) = pending_agent_reveal { + self.reveal_endpoint_agent(endpoint_id, &pane_id, agent_body_height); + } true } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 901c5a28..b975b2f6 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -861,6 +861,7 @@ pub(crate) struct ClientShellState { pub(super) remote_collapsed_groups: HashMap<ClientEndpointId, HashSet<String>>, pub(super) workspace_scroll: usize, pub(super) agent_scroll: usize, + pub(super) pending_agent_reveal: Option<(ClientEndpointId, String)>, pub(super) tab_scroll: usize, pub(super) mobile_switcher_scroll: usize, pub(super) reveal_focused_workspace: bool, @@ -1023,6 +1024,7 @@ impl ClientShellState { remote_collapsed_groups, workspace_scroll: 0, agent_scroll: 0, + pending_agent_reveal: None, tab_scroll: 0, mobile_switcher_scroll: 0, reveal_focused_workspace: true, diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 846806f1..e4914971 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -109,6 +109,154 @@ fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) { (state, remote) } +#[test] +fn agent_navigation_reveals_offscreen_targets() { + use crate::input::KeybindAction; + + for action in [ + KeybindAction::NextAgent, + KeybindAction::PreviousAgent, + KeybindAction::FocusAgent(0), + ] { + let (mut state, remote) = state_with_scrollable_agents(); + let (endpoint_id, pane_id) = match action { + KeybindAction::NextAgent => (ClientEndpointId::Local, "pane_2"), + KeybindAction::PreviousAgent => (remote, "pane_8"), + _ => (ClientEndpointId::Local, "pane_1"), + }; + state.agent_scroll = if action == KeybindAction::PreviousAgent { + 0 + } else { + state.hits.agent_max_scroll + }; + state.compose(100, 28).unwrap(); + assert!(!state + .hits + .endpoint_agents + .iter() + .any(|(_, endpoint, pane)| { endpoint == &endpoint_id && pane == pane_id })); + + let mut outcome = ClientShellInput::default(); + assert!(state.handle_endpoint_navigation(action, &mut outcome)); + assert!(outcome.repaint, "agent navigation must request a frame"); + if endpoint_id != state.active_endpoint_id { + assert!(state.activate_endpoint_projection(&endpoint_id)); + } + state.compose(100, 28).unwrap(); + assert!( + state + .hits + .endpoint_agents + .iter() + .any(|(_, endpoint, pane)| { endpoint == &endpoint_id && pane == pane_id }), + "{action:?} must reveal the selected agent" + ); + } +} + +#[test] +fn agent_navigation_reveals_target_using_destination_sort() { + use crate::api::schema::{ + AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, AgentViewSortOrder, + }; + + let (mut state, remote) = state_with_scrollable_agents(); + for (endpoint_id, base) in [(ClientEndpointId::Local, 0), (remote.clone(), 8)] { + let mut projection = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap() + .snapshot + .clone() + .unwrap(); + for (index, agent) in projection.agents.iter_mut().enumerate() { + agent.state_change_seq = base + index as u64; + } + if endpoint_id == remote { + projection.agent_view_label = Some("recent".into()); + } + state.set_endpoint_snapshot(&endpoint_id, projection); + } + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, None); + let mut view = current_workspace_view(); + view.label = Some("recent".into()); + view.filter = None; + view.sort = vec![AgentViewSort { + field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::StateChangeSeq), + order: AgentViewSortOrder::Desc, + }]; + state.set_test_endpoint_agent_view(&remote, Some(view)); + state.compose(100, 28).unwrap(); + + let mut outcome = ClientShellInput::default(); + assert!(state + .handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(15), &mut outcome,)); + assert!(matches!( + outcome.actions.as_slice(), + [ClientShellAction::ActivateEndpoint { + endpoint_id, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if endpoint_id == &remote && pane_id == "pane_8" + )); + // A superseded handoff restores its source before activating the new target. + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + state.compose(100, 28).unwrap(); + assert!(state.activate_endpoint_projection(&remote)); + state.compose(100, 28).unwrap(); + assert!(state + .hits + .endpoint_agents + .iter() + .any(|(_, endpoint, pane)| { endpoint == &remote && pane == "pane_8" })); +} + +#[test] +fn agent_navigation_reveal_is_cancelled_by_another_selection() { + for select_pane in [false, true] { + let (mut state, remote) = state_with_scrollable_agents(); + let scroll = state.agent_scroll; + let mut outcome = ClientShellInput::default(); + assert!(state + .handle_endpoint_navigation(crate::input::KeybindAction::PreviousAgent, &mut outcome,)); + assert_eq!(state.agent_scroll, scroll); + if select_pane { + assert!(state.focus_or_activate( + remote.clone(), + ClientEndpointFocusTarget::Pane("pane_1".into()), + &mut outcome, + )); + } else { + assert!(state.activate_endpoint(remote.clone(), &mut outcome)); + } + assert!(state.activate_endpoint_projection(&remote)); + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, scroll); + } +} + +#[test] +fn agent_navigation_keeps_scroll_when_target_is_visible() { + let (mut state, _) = state_with_scrollable_agents(); + let (_, endpoint_id, pane_id) = state.hits.endpoint_agents[1].clone(); + let targets = super::super::aggregate_navigation::online_agent_targets( + &state.endpoints, + &state.active_endpoint_id, + state.config.agent_panel_sort, + ); + let index = targets + .iter() + .position(|target| target.endpoint_id == endpoint_id && target.pane_id == pane_id) + .unwrap(); + let scroll = state.agent_scroll; + assert!(state.handle_endpoint_navigation( + crate::input::KeybindAction::FocusAgent(index), + &mut ClientShellInput::default(), + )); + state.compose(100, 28).unwrap(); + assert_eq!(state.agent_scroll, scroll); +} + #[test] fn switching_machines_preserves_aggregate_agent_scroll_and_visible_rows() { let (mut state, remote) = state_with_scrollable_agents(); From 3f2a6e743f67bc947cfa06be25df106d00b9ee11 Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Fri, 18 Sep 2026 21:56:24 +0300 Subject: [PATCH 20/99] test: isolate integration assets from inherited omp environment (#4351) refs #4346 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Can Celik <ogulcancelik@gmail.com> --- src/integration/assets/herdr-agent-state.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/integration/assets/herdr-agent-state.test.ts b/src/integration/assets/herdr-agent-state.test.ts index 19b36d94..e0f0bd57 100644 --- a/src/integration/assets/herdr-agent-state.test.ts +++ b/src/integration/assets/herdr-agent-state.test.ts @@ -86,6 +86,8 @@ function createExtensionHarness() { } function configureIntegrationEnvironment(recordingSocketPath: string) { + // Tests may run inside an OMP shell; nested-session cases opt in explicitly. + delete process.env.OMPCODE; process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = recordingSocketPath; process.env.HERDR_PANE_ID = "test:p1"; From cc7c696cd0772e466da403eb4e3f00f4e8d8ae1e Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sat, 19 Sep 2026 14:47:18 +0300 Subject: [PATCH 21/99] feat: add configurable pane screen and scrollback clearing (#4383) --- docs/next/api/herdr-api.schema.json | 16 ++++ .../website/src/data/config-reference.json | 6 ++ src/api/mod.rs | 1 + src/api/schema.rs | 2 + src/api/server.rs | 1 + src/app/api.rs | 1 + src/app/api/panes.rs | 36 ++++++++ src/client/shell/actions.rs | 3 + .../shell/tests/popup_focus_projection.rs | 19 ++++ src/config/keybinds.rs | 3 + src/config/model.rs | 5 ++ src/ghostty/bindings.rs | 4 + src/ghostty/mod.rs | 4 + src/input/keybind_help.rs | 1 + src/input/keybindings.rs | 36 ++++++++ src/main.rs | 1 + src/pane.rs | 64 ++++++++++++++ src/pane/terminal.rs | 19 ++++ src/server/client_commands.rs | 7 +- src/server/headless/client_views.rs | 1 + src/terminal/runtime.rs | 4 + vendor/libghostty-vt.patches.md | 38 ++++++++ .../include/ghostty/vt/terminal.h | 8 ++ vendor/libghostty-vt/src/lib_vt.zig | 1 + vendor/libghostty-vt/src/terminal/c/main.zig | 1 + .../libghostty-vt/src/terminal/c/terminal.zig | 34 +++++++ ...-clear-screen-preserving-cursor-line.patch | 88 +++++++++++++++++++ 27 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 26299e79..6f8b4f20 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -5707,6 +5707,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.clear", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneTarget" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index cf102f4b..ece9a833 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -697,6 +697,12 @@ "default": "\"prefix+e\"", "description": "Open the focused pane scrollback in $EDITOR." }, + { + "key": "keys.clear_pane", + "type": "keybinding", + "default": "unset", + "description": "Clear the focused pane screen and scrollback, keeping the current cursor line and its visible soft-wrapped rows. Sends no input to the running program and does nothing on the alternate screen (for example, Vim). Unset by default; for example, clear_pane = \"prefix+ctrl+k\"." + }, { "key": "keys.copy_mode", "type": "keybinding", diff --git a/src/api/mod.rs b/src/api/mod.rs index d28a82ae..bd2983e4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -59,6 +59,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneFocusDirection(_) | Method::PaneResize(_) | Method::PaneScroll(_) + | Method::PaneClear(_) | Method::PaneEditScrollback(_) | Method::PaneFocus(_) | Method::PaneInputSet(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 7c58fb69..e6c678dc 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -165,6 +165,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")] diff --git a/src/api/server.rs b/src/api/server.rs index 55571d75..03724813 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -457,6 +457,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", diff --git a/src/app/api.rs b/src/app/api.rs index 1270feff..0900a09d 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1101,6 +1101,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); } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index a025f0b9..119d9298 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); @@ -2349,6 +2365,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/client/shell/actions.rs b/src/client/shell/actions.rs index 12d8cc16..e3885626 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -1064,6 +1064,9 @@ impl ClientShellState { pane_id: focused_pane, mode: PaneZoomMode::Toggle, })), + KeybindAction::ClearPane => Some(Method::PaneClear(PaneTarget { + pane_id: focused_pane?, + })), KeybindAction::EditScrollback => Some(Method::PaneEditScrollback(PaneTarget { pane_id: focused_pane?, })), diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 25d3b334..7f3ea137 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -884,6 +884,25 @@ fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() { assert!(state.chrome_drag.is_none()); } +#[test] +fn clear_pane_binding_targets_the_focused_endpoint_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut input = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClearPane), + &mut input, + ); + assert!(input.requests.is_empty()); + assert!(matches!( + &input.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneClear(target) + if target.pane_id == "pane_1") + )); +} + #[test] fn edit_scrollback_binding_targets_the_focused_endpoint_pane() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index bec1af66..5f4b46de 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -347,6 +347,7 @@ pub struct Keybinds { pub close_tab: ActionKeybinds, pub rename_pane: ActionKeybinds, pub edit_scrollback: ActionKeybinds, + pub clear_pane: ActionKeybinds, pub copy_mode: ActionKeybinds, pub focus_pane_left: ActionKeybinds, pub focus_pane_down: ActionKeybinds, @@ -515,6 +516,7 @@ impl Config { close_tab: empty_action!(), rename_pane: empty_action!(), edit_scrollback: empty_action!(), + clear_pane: empty_action!(), copy_mode: empty_action!(), focus_pane_left: empty_action!(), focus_pane_down: empty_action!(), @@ -662,6 +664,7 @@ impl Config { apply_action!(keybinds.close_tab, close_tab, source); apply_action!(keybinds.rename_pane, rename_pane, source); apply_action!(keybinds.edit_scrollback, edit_scrollback, source); + apply_action!(keybinds.clear_pane, clear_pane, source); apply_action!(keybinds.copy_mode, copy_mode, source); apply_action!(keybinds.focus_pane_left, focus_pane_left, source); apply_action!(keybinds.focus_pane_down, focus_pane_down, source); diff --git a/src/config/model.rs b/src/config/model.rs index 6ef6274d..930df026 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -405,6 +405,7 @@ pub struct KeysConfig { pub rename_pane: BindingConfig, /// Open the focused pane scrollback in $EDITOR. Default: "prefix+e". pub edit_scrollback: BindingConfig, + pub clear_pane: BindingConfig, /// Enter keyboard copy mode for the focused pane. Default: "prefix+[". pub copy_mode: BindingConfig, /// Focus the pane to the left. Default: "prefix+h". @@ -536,6 +537,7 @@ pub(crate) struct KeysConfigOverlay { rename_pane: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] edit_scrollback: Option<BindingConfig>, + clear_pane: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] copy_mode: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] @@ -646,6 +648,7 @@ impl<'de> Deserialize<'de> for KeysConfig { apply_field!(close_tab); apply_field!(rename_pane); apply_field!(edit_scrollback); + apply_field!(clear_pane); apply_field!(copy_mode); apply_field!(focus_pane_left); apply_field!(focus_pane_down); @@ -750,6 +753,7 @@ impl KeysConfig { copy_effective_action_field!(close_tab, keybinds.close_tab); copy_effective_action_field!(rename_pane, keybinds.rename_pane); copy_effective_action_field!(edit_scrollback, keybinds.edit_scrollback); + copy_effective_action_field!(clear_pane, keybinds.clear_pane); copy_effective_action_field!(copy_mode, keybinds.copy_mode); copy_effective_action_field!(focus_pane_left, keybinds.focus_pane_left); copy_effective_action_field!(focus_pane_down, keybinds.focus_pane_down); @@ -1118,6 +1122,7 @@ impl Default for KeysConfig { close_tab: BindingConfig::one("prefix+shift+x"), rename_pane: BindingConfig::one("prefix+shift+p"), edit_scrollback: BindingConfig::one("prefix+e"), + clear_pane: BindingConfig::default(), copy_mode: BindingConfig::one("prefix+["), focus_pane_left: BindingConfig::one("prefix+h"), focus_pane_down: BindingConfig::one("prefix+j"), diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index bfa71825..ceb2d699 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -3236,6 +3236,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/src/ghostty/mod.rs index d1748aad..82714118 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1619,6 +1619,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, diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs index e9462b9d..2f89b861 100644 --- a/src/input/keybind_help.rs +++ b/src/input/keybind_help.rs @@ -160,6 +160,7 @@ pub(crate) fn keybind_help_groups( entry(binding_label(&keybinds.close_pane), "close pane"), entry(binding_label(&keybinds.rename_pane), "rename pane"), entry(binding_label(&keybinds.edit_scrollback), "edit scrollback"), + entry(binding_label(&keybinds.clear_pane), "clear pane"), entry(binding_label(&keybinds.copy_mode), "copy mode"), entry(binding_label(&keybinds.zoom), "zoom pane"), entry(binding_label(&keybinds.resize_mode), "resize mode"), diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index d56de80c..9ffce2ee 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -52,6 +52,7 @@ pub(crate) enum KeybindAction { SplitHorizontal, ClosePane, EditScrollback, + ClearPane, CopyMode, Zoom, EnterResizeMode, @@ -120,6 +121,7 @@ pub(crate) fn resolve_non_indexed_action( (&keybinds.close_tab, KeybindAction::CloseTab), (&keybinds.rename_pane, KeybindAction::RenamePane), (&keybinds.edit_scrollback, KeybindAction::EditScrollback), + (&keybinds.clear_pane, KeybindAction::ClearPane), (&keybinds.copy_mode, KeybindAction::CopyMode), (&keybinds.focus_pane_left, KeybindAction::FocusPaneLeft), (&keybinds.focus_pane_down, KeybindAction::FocusPaneDown), @@ -258,6 +260,40 @@ mod tests { use super::*; + #[test] + fn clear_pane_is_unbound_by_default_and_configurable() { + assert!(crate::config::Config::default() + .keybinds() + .clear_pane + .bindings + .is_empty()); + let config: crate::config::Config = + toml::from_str("[keys]\nclear_pane = [\"super+k\", \"prefix+ctrl+k\"]").unwrap(); + assert!(config.collect_diagnostics().is_empty()); + let keybinds = config.keybinds(); + assert!(matches!( + resolve_direct_binding( + &keybinds, + &TerminalKey::new(KeyCode::Char('k'), KeyModifiers::SUPER) + ), + Some(KeybindMatch::Action(KeybindAction::ClearPane)) + )); + assert!(matches!( + resolve_prefix_binding( + &keybinds, + &TerminalKey::new(KeyCode::Char('k'), KeyModifiers::CONTROL) + ), + Some(KeybindMatch::Action(KeybindAction::ClearPane)) + )); + assert!(matches!( + resolve_prefix_binding( + &keybinds, + &TerminalKey::new(KeyCode::Char('k'), KeyModifiers::SHIFT) + ), + Some(KeybindMatch::Action(KeybindAction::SwapPaneUp)) + )); + } + #[test] fn one_shared_resolver_handles_direct_prefix_and_indexed_bindings() { let keybinds = Keybinds { diff --git a/src/main.rs b/src/main.rs index 8cfe9b0b..5d7582cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -172,6 +172,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # close_tab = "prefix+shift+x" # rename_pane = "prefix+shift+p" # edit_scrollback = "prefix+e" +# clear_pane = "" # unbound; e.g. "prefix+ctrl+k" # focus_pane_left = "prefix+h" # focus_pane_down = "prefix+j" # focus_pane_up = "prefix+k" diff --git a/src/pane.rs b/src/pane.rs index 29cbad2a..3e1c52a4 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3081,6 +3081,20 @@ impl PaneRuntime { self.compression.wake(); } + pub fn clear_screen(&self) -> Result<(), String> { + let guard = match self.content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + self.content_seq.fetch_add(1, Ordering::AcqRel); + let result = self.terminal.clear_screen(); + self.content_seq.fetch_add(1, Ordering::Release); + drop(guard); + self.compression.wake(); + mark_detection_content_changed(&self.detection_content_seq); + result + } + /// Reset scroll to live view (offset = 0). pub fn scroll_reset(&self) { self.terminal.scroll_reset(); @@ -3668,6 +3682,56 @@ impl PaneRuntime { mod tests { use super::*; + #[tokio::test] + async fn clear_pane_preserves_wrapped_input_and_unfinished_vt_sequence() { + let (runtime, mut rx) = PaneRuntime::test_with_channel_and_scrollback_bytes( + 10, + 5, + 100_000, + b"old\r\nold\r\nold\r\nold\r\nold\r\n\x1b[32m$ abcdefghijklmnop\x1b[1A\x1b[4G\x1b[", + 4, + ); + let before = runtime.content_seq(); + runtime.scroll_up(1); + runtime.clear_screen().unwrap(); + let snapshot = runtime.collect_dirty_patch_snapshot(10, 5).unwrap(); + assert!(snapshot.content_revision > before); + assert!(!matches!(snapshot.patch, TerminalDirtyPatchOutcome::Clean)); + let metrics = runtime.scroll_metrics().unwrap(); + assert_eq!(metrics.max_offset_from_bottom, 0); + assert_eq!(metrics.offset_from_bottom, 0); + let text = runtime.recent_unwrapped_text_snapshot(100).text; + assert!(text.contains("$ abcdefghijklmnop"), "{text:?}"); + assert!(!text.contains("old"), "{text:?}"); + runtime.test_process_pty_bytes(b"5 q"); + assert!(!runtime.visible_text().contains("5 q")); + assert!(rx.try_recv().is_err(), "clear must not send child input"); + } + + #[tokio::test] + async fn clear_pane_preserves_alternate_screen_and_primary_history() { + let runtime = PaneRuntime::test_with_scrollback_bytes( + 20, + 4, + 100_000, + b"one\r\ntwo\r\nthree\r\nfour\r\nfive\x1b[?1049halt app", + ); + let before = runtime.visible_text(); + runtime.clear_screen().unwrap(); + assert_eq!(runtime.visible_text(), before); + runtime.test_process_pty_bytes(b"\x1b[?1049l"); + assert!(runtime + .recent_unwrapped_text_snapshot(100) + .text + .contains("one")); + runtime.clear_screen().unwrap(); + assert!(!runtime + .recent_unwrapped_text_snapshot(100) + .text + .contains("one")); + assert!(runtime.visible_text().contains("five")); + } + #[tokio::test] async fn dirty_patch_snapshot_keeps_clean_metadata_and_terminal_fallback() { let (runtime, _rx) = PaneRuntime::test_with_channel(20, 4); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 4a6bc173..4d7f07b1 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -258,6 +258,10 @@ impl PaneTerminal { self.ghostty.scroll_reset(); } + pub fn clear_screen(&self) -> Result<(), String> { + self.ghostty.clear_screen() + } + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { self.ghostty.set_scroll_offset_from_bottom(lines); } @@ -1773,6 +1777,21 @@ impl GhosttyPaneTerminal { } } + pub fn clear_screen(&self) -> Result<(), String> { + let mut core = self + .core + .lock() + .map_err(|_| "terminal lock poisoned".to_owned())?; + if core.terminal.clear_screen() { + #[cfg(windows)] + { + core.recent_fallback = windows_recent_fallback::Cache::default(); + windows_recent_fallback::update(&mut core); + } + } + Ok(()) + } + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { if let Ok(mut core) = self.core.lock() { #[cfg(windows)] diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 4494f053..87824c85 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -18,6 +18,7 @@ const CLIENT_SHELL_METHODS: &[&str] = &[ "integration.install", "integration.list", "layout.set_split_ratio", + "pane.clear", "pane.close", "pane.copy_motion", "pane.copy_search", @@ -287,7 +288,11 @@ mod tests { ))) .expect("endpoint method shape fixture"); let mut actual = endpoint_method_shape_digests(); - // Freeze the additive method separately without rewriting the published fixture. + // Freeze additive methods separately without rewriting the published fixture. + assert_eq!( + actual.remove("pane.clear").as_deref(), + Some("0301d288ba198ddaa427dd7421c71911cccaf4ea03544531efa8b67ca21b08f6") + ); assert_eq!( actual.remove("pane.link.resolve").as_deref(), Some("f5e4a3e01453ae7b188f127ce951c12c20e0bebcc17cc364eeb6d1a01fd5bf81") diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 4e0bd2e2..62fd04e4 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -262,6 +262,7 @@ impl HeadlessServer { | Method::PaneRename(_) | Method::PaneResize(_) | Method::PaneScroll(_) + | Method::PaneClear(_) | Method::PaneSplit(_) | Method::PaneSwap(_) | Method::PaneZoom(_) diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 46795791..a10bec78 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -270,6 +270,10 @@ impl TerminalRuntime { self.0.scroll_reset(); } + pub fn clear_screen(&self) -> Result<(), String> { + self.0.clear_screen() + } + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { self.0.set_scroll_offset_from_bottom(lines); } diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 10596292..cd7f44f6 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -132,3 +132,41 @@ just test-one link_activation just test-one ctrl_click just check ``` + +## 0006 clear screen while preserving the cursor line + +status: active + +patch: `vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch` + +herdr issue: none; requested in https://github.com/herdrdev/herdr/discussions/545 + +upstream discussion: not opened + +upstream pr: not opened + +vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/terminal.h` +- `vendor/libghostty-vt/src/lib_vt.zig` +- `vendor/libghostty-vt/src/terminal/c/main.zig` +- `vendor/libghostty-vt/src/terminal/c/terminal.zig` + +reason: Herdr needs an explicit screen/history clear that preserves the cursor's +visible soft-wrapped line without writing to the child or interrupting a partial +VT sequence. The new C function operates directly on the screen, leaves alternate +screens untouched, clears image placements, and marks the result dirty. + +remove when: the vendored C API provides an equivalent parser-independent clear +operation preserving the visible cursor line, and Herdr passes the checks below +using it without this patch. + +verification: + +```sh +just test-one clear_pane +just maintenance-test +just check +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index ad5eec54..81f9ebd0 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h +++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -2007,6 +2007,14 @@ GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal); */ GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal); +/** + * Clear screen and history, retaining the cursor's soft-wrapped active line. + * Does not alter the VT parser or write to the child process. Returns false + * without changing the terminal on the alternate screen or for a NULL handle. + * Otherwise returns true and moves the retained line to the top of the screen. + */ +GHOSTTY_API bool ghostty_terminal_clear_screen(GhosttyTerminal terminal); + /** * Resize the terminal to the given dimensions. * diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index 48536568..05bc0baa 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -317,6 +317,7 @@ comptime { @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); + @export(&c.terminal_clear_screen, .{ .name = "ghostty_terminal_clear_screen" }); @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 0ba1224a..5ac299ab 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -183,6 +183,7 @@ pub const sys_set = sys.set; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; +pub const terminal_clear_screen = terminal.clearScreen; pub const terminal_resize = terminal.resize; pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index 73097573..91bbe310 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -1504,6 +1504,40 @@ pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void { t.fullReset(); } +/// Clear output without feeding synthetic bytes into the child's VT stream. +pub fn clearScreen(terminal_: Terminal) callconv(lib.calling_conv) bool { + const t: *ZigTerminal = (terminal_ orelse return false).terminal; + if (t.screens.active_key == .alternate) return false; + const screen = t.screens.active; + + var first = screen.cursor.y; + while (first > 0) { + const pin = screen.pages.pin(.{ .active = .{ .y = first } }).?; + if (!pin.rowAndCell().row.wrap_continuation) break; + first -= 1; + } + var last = screen.cursor.y; + while (last + 1 < t.rows) { + const pin = screen.pages.pin(.{ .active = .{ .y = last } }).?; + if (!pin.rowAndCell().row.wrap) break; + last += 1; + } + + screen.clearSelection(); + if (last + 1 < t.rows) { + screen.clearRows(.{ .active = .{ .y = last + 1 } }, null, false); + } + screen.eraseHistory(null); + if (first > 0) screen.eraseActive(first - 1); + screen.pages.pin(.{ .active = .{} }).?.rowAndCell().row.wrap_continuation = false; + screen.scroll(.active); + if (comptime build_options.kitty_graphics) { + screen.kitty_images.delete(t.io(), screen.alloc, t, .{ .all = true }); + } + t.flags.dirty.clear = true; + return true; +} + /// C: GhosttyKittyGraphics pub const KittyGraphics = kitty_gfx_c.KittyGraphics; diff --git a/vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch b/vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch new file mode 100644 index 00000000..86fcee9a --- /dev/null +++ b/vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch @@ -0,0 +1,88 @@ +diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +index ad5eec54..81f9ebd0 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +@@ -2007,6 +2007,14 @@ GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal); + */ + GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal); + ++/** ++ * Clear screen and history, retaining the cursor's soft-wrapped active line. ++ * Does not alter the VT parser or write to the child process. Returns false ++ * without changing the terminal on the alternate screen or for a NULL handle. ++ * Otherwise returns true and moves the retained line to the top of the screen. ++ */ ++GHOSTTY_API bool ghostty_terminal_clear_screen(GhosttyTerminal terminal); ++ + /** + * Resize the terminal to the given dimensions. + * +diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig +index 48536568..05bc0baa 100644 +--- a/vendor/libghostty-vt/src/lib_vt.zig ++++ b/vendor/libghostty-vt/src/lib_vt.zig +@@ -317,6 +317,7 @@ comptime { + @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); + @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); ++ @export(&c.terminal_clear_screen, .{ .name = "ghostty_terminal_clear_screen" }); + @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); + @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); + @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); +diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig +index 0ba1224a..5ac299ab 100644 +--- a/vendor/libghostty-vt/src/terminal/c/main.zig ++++ b/vendor/libghostty-vt/src/terminal/c/main.zig +@@ -183,6 +183,7 @@ pub const sys_set = sys.set; + pub const terminal_new = terminal.new; + pub const terminal_free = terminal.free; + pub const terminal_reset = terminal.reset; ++pub const terminal_clear_screen = terminal.clearScreen; + pub const terminal_resize = terminal.resize; + pub const terminal_set = terminal.set; + pub const terminal_vt_write = terminal.vt_write; +diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig +index 73097573..91bbe310 100644 +--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig ++++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig +@@ -1504,6 +1504,40 @@ pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void { + t.fullReset(); + } + ++/// Clear output without feeding synthetic bytes into the child's VT stream. ++pub fn clearScreen(terminal_: Terminal) callconv(lib.calling_conv) bool { ++ const t: *ZigTerminal = (terminal_ orelse return false).terminal; ++ if (t.screens.active_key == .alternate) return false; ++ const screen = t.screens.active; ++ ++ var first = screen.cursor.y; ++ while (first > 0) { ++ const pin = screen.pages.pin(.{ .active = .{ .y = first } }).?; ++ if (!pin.rowAndCell().row.wrap_continuation) break; ++ first -= 1; ++ } ++ var last = screen.cursor.y; ++ while (last + 1 < t.rows) { ++ const pin = screen.pages.pin(.{ .active = .{ .y = last } }).?; ++ if (!pin.rowAndCell().row.wrap) break; ++ last += 1; ++ } ++ ++ screen.clearSelection(); ++ if (last + 1 < t.rows) { ++ screen.clearRows(.{ .active = .{ .y = last + 1 } }, null, false); ++ } ++ screen.eraseHistory(null); ++ if (first > 0) screen.eraseActive(first - 1); ++ screen.pages.pin(.{ .active = .{} }).?.rowAndCell().row.wrap_continuation = false; ++ screen.scroll(.active); ++ if (comptime build_options.kitty_graphics) { ++ screen.kitty_images.delete(t.io(), screen.alloc, t, .{ .all = true }); ++ } ++ t.flags.dirty.clear = true; ++ return true; ++} ++ + /// C: GhosttyKittyGraphics + pub const KittyGraphics = kitty_gfx_c.KittyGraphics; + From e0507237ad8d41f60aece10d00ef29ce2339ca34 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sat, 19 Sep 2026 15:43:47 +0300 Subject: [PATCH 22/99] feat: show every agent and terminal in the go to picker (#4384) --- .../website/src/content/docs/ja/keyboard.mdx | 2 + .../website/src/content/docs/keyboard.mdx | 2 + .../src/content/docs/zh-cn/keyboard.mdx | 2 + src/client/shell/aggregate_navigation.rs | 143 +++-- src/client/shell/composition.rs | 2 + src/client/shell/mouse.rs | 53 +- src/client/shell/overlay_input.rs | 105 ++-- src/client/shell/overlays.rs | 253 +++++--- src/client/shell/state.rs | 12 +- src/client/shell/tests/copy.rs | 552 +++++++++++++++++- src/client/shell/tests/endpoints.rs | 57 +- src/client/shell/tests/graphics.rs | 1 - .../shell/tests/popup_focus_projection.rs | 1 - 13 files changed, 919 insertions(+), 266 deletions(-) diff --git a/docs/next/website/src/content/docs/ja/keyboard.mdx b/docs/next/website/src/content/docs/ja/keyboard.mdx index 2e547b64..ef0dfc2a 100644 --- a/docs/next/website/src/content/docs/ja/keyboard.mdx +++ b/docs/next/website/src/content/docs/ja/keyboard.mdx @@ -62,6 +62,8 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚 完全なキーマップとバインドの構文は[キーバインドリファレンス](/ja/docs/configuration/#キーバインド)にあります。 +Goto ピッカー (`prefix+g`) は、各エージェントとターミナルをワークスペースごとに一行ずつ表示します。分割タブのペインも隠さず表示されます。行をクリックするか Enter を押すと、そのペインへ直接移動します。`/` で名前、エージェントの種類、タブ、ワークスペース、ブランチ、パスを検索できます。検索入力中でなければ上下矢印キーまたは `j/k` で行を移動します。左右矢印キーは前後のワークスペースの最初のエージェントまたはターミナルへ移動し、端では折り返しません。検索入力中は左右矢印キーでテキストカーソルを動かします。`b/w/i/d` で blocked、working、idle、done のエージェントに絞り込み、`a` ですべてのターミナルに戻します。選択した移動先の場所とパスは一覧の下に表示されます。一覧が収まらない場合は、マウスホイール、スクロールバーのトラックのクリック、つまみのドラッグで、移動先を開かずにスクロールできます。 + ## テキスト欄の編集 Herdr の名前入力、worktree のブランチ名、フィルター、コピーモードの検索欄では、カーソル位置で編集できます。以下は Herdr 自身の入力欄の操作であり、ペイン内のシェルやエージェントの操作は変わりません。 diff --git a/docs/next/website/src/content/docs/keyboard.mdx b/docs/next/website/src/content/docs/keyboard.mdx index f1e1394c..9e3a7053 100644 --- a/docs/next/website/src/content/docs/keyboard.mdx +++ b/docs/next/website/src/content/docs/keyboard.mdx @@ -62,6 +62,8 @@ Workspaces and session: The full keymap and the binding syntax live in the [keybinding reference](/docs/configuration/#keybindings). +The Goto picker (`prefix+g`) lists each agent or terminal on its own row, grouped by workspace. Split tabs never hide their panes behind another row. Click a row or press Enter to jump directly to it. Press `/` to search names, agent kinds, tabs, workspaces, branches, or paths; use Up/Down or `j/k` to move between rows when not typing a search. Left/Right jumps to the first agent or terminal in the previous/next workspace section, without wrapping. While editing a search, Left/Right moves the text cursor instead. Outside search, `b/w/i/d` filter blocked, working, idle, or done agents, and `a` shows all terminals again. The selected destination's location and path appear below the list. When the list overflows, use the mouse wheel, click the scrollbar track, or drag its thumb to scroll without opening a destination. + ## Editing text fields Herdr's naming dialogs, worktree branch fields, filters, and copy-mode search support editing at the cursor. These shortcuts apply to Herdr-owned fields, not to the shell or agent inside a pane. diff --git a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx index f1472966..486886f9 100644 --- a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx +++ b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx @@ -62,6 +62,8 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智 完整键位和绑定语法见[按键绑定参考](/zh-cn/docs/configuration/#按键绑定)。 +Goto 选择器 (`prefix+g`) 按工作区分组,每个代理或终端各占一行,分割标签页中的窗格也会直接显示。点击一行或按回车即可跳转到对应窗格。按 `/` 搜索名称、代理类型、标签页、工作区、分支或路径;未输入搜索时,用上下方向键或 `j/k` 在各行之间移动,左右方向键跳到上一个或下一个工作区中的第一个代理或终端,到达两端后不会循环。编辑搜索内容时,左右方向键只移动文本光标。用 `b/w/i/d` 筛选 blocked、working、idle 或 done 状态的代理,按 `a` 重新显示所有终端。所选目标的位置和路径显示在列表下方。列表超出可视区域时,可以使用鼠标滚轮、点击滚动条轨道或拖动滑块来滚动,而不会打开目标。 + ## 编辑文本输入框 Herdr 的命名对话框、worktree 分支名、筛选框和复制模式搜索框支持在光标处编辑。以下快捷键适用于 Herdr 自己的输入框,不改变窗格内 shell 或智能体的按键行为。 diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index c0195f93..9d3108cc 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -299,49 +299,92 @@ pub(super) fn navigator_rows( let endpoint_query_matches = !query.is_empty() && text(&endpoint.label); let mut endpoint_rows = Vec::new(); if let Some(snapshot) = endpoint.snapshot.as_deref() { + let agents = snapshot + .agents + .iter() + .map(|agent| (agent.pane_id.as_str(), agent)) + .collect::<HashMap<_, _>>(); for workspace in &snapshot.workspaces { - let workspace_meta = workspace.branch.clone().unwrap_or_default(); + let workspace_matches = endpoint_query_matches + || text(&workspace.label) + || workspace.branch.as_deref().is_some_and(text); let mut children = Vec::new(); - for tab in snapshot + let workspace_tabs = snapshot .tabs .iter() - .filter(|tab| tab.workspace_id == workspace.workspace_id) - { - let mut panes = Vec::new(); - for (index, pane) in snapshot + .filter(|tab| tab.workspace_id == workspace.workspace_id); + let multiple_tabs = workspace_tabs.clone().nth(1).is_some(); + for tab in workspace_tabs { + let tab_matches = workspace_matches || text(&tab.label); + let tab_panes = snapshot .panes .iter() .filter(|pane| pane.tab_id == tab.tab_id) - .enumerate() - { - let agent = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane.pane_id); + .collect::<Vec<_>>(); + for (index, pane) in tab_panes.iter().enumerate() { + let agent = agents.get(pane.pane_id.as_str()).copied(); let status = agent .map_or(crate::api::schema::AgentStatus::Unknown, |agent| { agent.agent_status }); - let label = pane + let agent_kind = agent.and_then(|agent| { + agent.agent.as_deref().or(agent.display_agent.as_deref()) + }); + let name = pane .label - .clone() - .or_else(|| agent.and_then(|agent| agent.name.clone())) - .or_else(|| agent.and_then(|agent| agent.display_agent.clone())) - .or_else(|| agent.and_then(|agent| agent.title.clone())) - .unwrap_or_else(|| format!("pane {}", index + 1)); + .as_deref() + .or_else(|| agent.and_then(|agent| agent.name.as_deref())); + let title = agent.and_then(|agent| { + agent + .title + .as_deref() + .or(agent.terminal_title_stripped.as_deref()) + }); + let tab_name = (tab.custom_label || tab.label.parse::<usize>().is_err()) + .then_some(tab.label.as_str()); + let label = if tab_panes.len() == 1 { + match name.or(tab_name).or(title) { + Some(label) => label.to_owned(), + None if multiple_tabs => { + format!("{} · {}", agent_kind.unwrap_or("terminal"), tab.label) + } + None => workspace.label.clone(), + } + } else { + let pane_name = name.or(title).or(agent_kind).unwrap_or("terminal"); + match tab_name { + Some(tab_name) if tab_name != pane_name => { + format!("{tab_name} · {pane_name} · {}", index + 1) + } + _ => format!("{pane_name} · {}", index + 1), + } + }; let meta = pane .foreground_cwd - .clone() - .or_else(|| pane.cwd.clone()) + .as_deref() + .or(pane.cwd.as_deref()) .unwrap_or_default(); - if !filtering - || filter(status) - && (endpoint_query_matches || text(&label) || text(&meta)) + if filter(status) + && (tab_matches + || text(&label) + || text(meta) + || pane.cwd.as_deref().is_some_and(text) + || agent_kind.is_some_and(text) + || title.is_some_and(text) + || agent + .and_then(|agent| agent.display_agent.as_deref()) + .is_some_and(text) + || text(&pane.pane_id)) { - panes.push(ClientNavigatorRow { - depth: 2 + depth_offset, + children.push(ClientNavigatorRow { + depth: 1 + depth_offset, label, - meta, + meta: meta.to_owned(), + detail: format!( + "{} / {} / {}", + workspace.label, tab.label, pane.pane_id + ), + agent: agent_kind.map(str::to_owned), status: Some(status), stale, current: endpoint.endpoint_id == *active_endpoint_id @@ -353,40 +396,17 @@ pub(super) fn navigator_rows( }); } } - if !filtering - || filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label)) - || !panes.is_empty() - { - children.push(ClientNavigatorRow { - depth: 1 + depth_offset, - label: tab.label.clone(), - meta: format!( - "{} panes", - snapshot - .panes - .iter() - .filter(|pane| pane.tab_id == tab.tab_id) - .count() - ), - status: None, - stale, - current: false, - target: ClientNavigatorTarget::Tab { - endpoint_id: endpoint.endpoint_id.clone(), - tab_id: tab.tab_id.clone(), - }, - }); - children.extend(panes); - } } - let workspace_matches = filter(workspace.agent_status) - && (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta)); - if !filtering || workspace_matches || !children.is_empty() { - let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()); + if !filtering + || !children.is_empty() + || (navigator.filter.is_none() && !query.is_empty() && workspace_matches) + { endpoint_rows.push(ClientNavigatorRow { depth: depth_offset, label: workspace.label.clone(), - meta: workspace_meta, + meta: workspace.branch.clone().unwrap_or_default(), + detail: workspace.new_workspace_cwd.clone(), + agent: None, status: None, stale, current: false, @@ -395,9 +415,7 @@ pub(super) fn navigator_rows( workspace_id: workspace.workspace_id.clone(), }, }); - if navigator.expanded_workspaces.contains(&key) || filtering { - endpoint_rows.extend(children); - } + endpoint_rows.extend(children); } } } @@ -407,6 +425,8 @@ pub(super) fn navigator_rows( depth: 0, label: endpoint.label.to_owned(), meta: String::new(), + detail: String::new(), + agent: None, status: None, stale, current: false, @@ -427,7 +447,10 @@ pub(super) fn navigator_selected_index( ) -> Option<usize> { match navigator.selected.as_ref() { Some(target) => rows.iter().position(|row| row.target == *target), - None => (!rows.is_empty()).then_some(0), + None => rows + .iter() + .position(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .or_else(|| (!rows.is_empty()).then_some(0)), } } diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index e0888d0d..10dc6633 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -649,6 +649,8 @@ impl ClientShellState { self.hits.navigator_popup = rendered.navigator_popup; self.hits.navigator_search = rendered.navigator_search; self.hits.navigator_rows = rendered.navigator_rows; + self.hits.navigator_scrollbar = rendered.navigator_scrollbar; + self.hits.navigator_scroll_metrics = rendered.navigator_scroll_metrics; self.hits.worktree_search = rendered.worktree_search; self.hits.worktree_rows = rendered.worktree_rows; self.hits.help_popup = rendered.help_popup; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 0b9326d0..a7e0dd81 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1032,6 +1032,22 @@ impl ClientShellState { } return; } + Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.navigator_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.navigator_scrollbar, + mouse.row, + *grab_row_offset, + ); + self.scroll_navigator_to( + metrics.max_offset_from_bottom.saturating_sub(offset), + metrics.viewport_rows, + ); + outcome.repaint = true; + } + return; + } Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => { if let (Some(metrics), Some(ClientShellOverlay::Help(help))) = (self.hits.help_scroll_metrics, self.overlay.as_mut()) @@ -1317,6 +1333,7 @@ impl ClientShellState { ClientChromeDrag::WorkspaceScrollbar { .. } | ClientChromeDrag::AgentScrollbar { .. } | ClientChromeDrag::HelpScrollbar { .. } + | ClientChromeDrag::NavigatorScrollbar { .. } | ClientChromeDrag::ProductAnnouncementScrollbar { .. } | ClientChromeDrag::ReleaseNotesScrollbar { .. } => {} } @@ -1613,7 +1630,29 @@ impl ClientShellState { } } MouseEventKind::Down(MouseButton::Left) => { - if super::contains(self.hits.navigator_search, point) { + if super::contains(self.hits.navigator_scrollbar, point) { + if let Some(metrics) = self.hits.navigator_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.navigator_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.navigator_scrollbar, + mouse.row, + ); + self.scroll_navigator_to( + metrics.max_offset_from_bottom.saturating_sub(offset), + metrics.viewport_rows, + ); + outcome.repaint = true; + } + } + } else if super::contains(self.hits.navigator_search, point) { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { @@ -1621,19 +1660,13 @@ impl ClientShellState { navigator.filter = None; } outcome.repaint = true; - } else if let Some((rect, target)) = row_hit { + } else if let Some((_, target)) = row_hit { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { - navigator.selected = Some(target.clone()); - } - let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. }); - if workspace && mouse.column <= rect.x.saturating_add(3) { - self.toggle_selected_navigator_workspace(); - outcome.repaint = true; - } else { - self.accept_navigator_selection(outcome); + navigator.selected = Some(target); } + self.accept_navigator_selection(outcome); } else if !super::contains(self.hits.navigator_popup, point) { self.overlay = None; outcome.repaint = true; diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 7cf151cb..e4248304 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -191,21 +191,12 @@ impl ClientShellState { } pub(super) fn open_navigator_overlay(&mut self) { - let expanded_workspaces = - super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints) - .flat_map(|endpoint| { - endpoint.snapshot.workspaces.iter().map(move |workspace| { - (endpoint.endpoint_id.clone(), workspace.workspace_id.clone()) - }) - }) - .collect(); let mut navigator = ClientNavigatorOverlay { query: TextEditor::default(), search_focused: false, selected: None, scroll: 0, filter: None, - expanded_workspaces, }; let rows = render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator); @@ -233,6 +224,55 @@ impl ClientShellState { navigator.selected = Some(rows[next].target.clone()); } + pub(super) fn scroll_navigator_to(&mut self, scroll: usize, viewport_rows: usize) { + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator); + let viewport_rows = viewport_rows.max(1); + navigator.scroll = scroll.min(rows.len().saturating_sub(viewport_rows)); + let selected = + super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0); + // Keep the selection in the dragged viewport so rendering does not snap back to it. + let selected = selected.clamp(navigator.scroll, navigator.scroll + viewport_rows - 1); + navigator.selected = rows.get(selected).map(|row| row.target.clone()); + } + + fn move_navigator_workspace(&mut self, forward: bool) { + let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else { + return; + }; + let rows = + render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator); + let Some(selected) = + super::aggregate_navigation::navigator_selected_index(&rows, navigator) + else { + return; + }; + let section = rows[..=selected] + .iter() + .rposition(|row| !matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .unwrap_or(selected); + let mut destinations = rows.windows(2).enumerate().filter(|(index, pair)| { + matches!(pair[0].target, ClientNavigatorTarget::Workspace { .. }) + && matches!(pair[1].target, ClientNavigatorTarget::Pane { .. }) + && if forward { + *index > section + } else { + *index < section + } + }); + let destination = if forward { + destinations.next() + } else { + destinations.next_back() + }; + if let Some((_, pair)) = destination { + navigator.selected = Some(pair[1].target.clone()); + } + } + pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) { let target = self.overlay.as_ref().and_then(|overlay| match overlay { ClientShellOverlay::Navigator(navigator) => { @@ -260,12 +300,6 @@ impl ClientShellState { ClientEndpointFocusTarget::Workspace(workspace_id), outcome, ), - ClientNavigatorTarget::Tab { - endpoint_id, - tab_id, - } => { - self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome) - } ClientNavigatorTarget::Pane { endpoint_id, pane_id, @@ -281,37 +315,6 @@ impl ClientShellState { outcome.repaint = true; } - pub(super) fn toggle_selected_navigator_workspace(&mut self) { - let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay { - ClientShellOverlay::Navigator(navigator) => { - let rows = render::client_navigator_rows( - &self.endpoints, - &self.active_endpoint_id, - navigator, - ); - super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then( - |target| match target { - ClientNavigatorTarget::Workspace { - endpoint_id, - workspace_id, - } => Some((endpoint_id, workspace_id)), - _ => None, - }, - ) - } - _ => None, - }); - if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) = - (workspace_key, self.overlay.as_mut()) - { - if !navigator.expanded_workspaces.remove(&workspace_key) { - navigator.expanded_workspaces.insert(workspace_key); - } - navigator.selected = None; - navigator.scroll = 0; - } - } - pub(super) fn workspace_action_id(&self) -> Option<String> { self.navigate_workspace_id .as_ref() @@ -676,6 +679,11 @@ impl ClientShellState { } return; } + if matches!(code, KeyCode::Left | KeyCode::Right) && modifiers.is_empty() { + self.move_navigator_workspace(code == KeyCode::Right); + outcome.repaint = true; + return; + } if code == KeyCode::Backspace && modifiers.is_empty() { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { if navigator.filter.take().is_some() { @@ -762,11 +770,6 @@ impl ClientShellState { outcome.repaint = true; return; } - if code == KeyCode::Char(' ') && modifiers.is_empty() { - self.toggle_selected_navigator_workspace(); - outcome.repaint = true; - return; - } return; } diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 07417bd0..c8eee5f4 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -13,6 +13,8 @@ pub(crate) struct OverlayRender { pub(crate) navigator_popup: Rect, pub(crate) navigator_search: Rect, pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, + pub(crate) navigator_scrollbar: Rect, + pub(crate) navigator_scroll_metrics: Option<crate::pane::ScrollMetrics>, pub(crate) worktree_search: Rect, pub(crate) worktree_rows: Vec<(Rect, usize)>, pub(crate) help_popup: Rect, @@ -685,24 +687,6 @@ fn render_rename_overlay( }) } -/// Mark following siblings in preorder without rescanning descendants for each row. -/// The reverse stack contains at most one entry per depth; every entry is pushed -/// and popped at most once, so this pass is linear in the number of rows. -fn navigator_following_siblings(rows: &[ClientNavigatorRow]) -> Vec<bool> { - let mut following = vec![false; rows.len()]; - let mut depths = Vec::new(); - for (index, row) in rows.iter().enumerate().rev() { - while depths.last().is_some_and(|depth| *depth > row.depth) { - depths.pop(); - } - following[index] = depths.last() == Some(&row.depth); - if !following[index] { - depths.push(row.depth); - } - } - following -} - fn render_navigator_overlay( b: &mut Buffer, n: &ClientNavigatorOverlay, @@ -711,16 +695,27 @@ fn render_navigator_overlay( p: &Palette, ) -> Option<OverlayRender> { let a = b.area; - let mx = (a.width / 16).max(2); - let my = (a.height / 10).max(1); + let width = a.width.saturating_sub(4).min(116); + let height = a.height.saturating_sub(2).min(42); + if width < 4 || height < 9 { + return None; + } let q = Rect::new( - a.x + mx, - a.y + my, - a.width.saturating_sub(mx * 2).max(4), - a.height.saturating_sub(my * 2).max(4), + a.x + (a.width - width) / 2, + a.y + (a.height - height) / 2, + width, + height, ) .intersection(a); let i = panel(b, q, p.accent, p.panel_bg)?; + put_text( + b, + q.x + 2, + q.y, + q.width.saturating_sub(4), + " Go to ", + Style::default().fg(p.accent).bg(p.panel_bg), + ); let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n); let search = if n.search_focused { " / ".to_owned() @@ -735,26 +730,32 @@ fn render_navigator_overlay( } ) } else if n.query.is_empty() { - " / search panes".to_owned() + " / search agents and terminals".to_owned() } else { format!(" / {}", n.query) }; + let terminal_count = rows + .iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .count(); + let count = format!( + "{terminal_count} {}", + if terminal_count == 1 { + "terminal" + } else { + "terminals" + } + ); put_text( b, i.x, i.y, - i.width, + i.width.saturating_sub(display_width(&count) + 1), &search, Style::default() .fg(if n.search_focused { p.text } else { p.overlay0 }) .bg(p.panel_bg), ); - let count = format!( - "{} panes", - rows.iter() - .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) - .count() - ); let cursor = if n.search_focused { text_editor::render( b, @@ -785,7 +786,7 @@ fn render_navigator_overlay( &"─".repeat(i.width as usize), Style::default().fg(p.surface1).bg(p.panel_bg), ); - let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4)); + let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(5)); let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0); let max = rows.len().saturating_sub(body.height as usize); let scroll = n @@ -793,17 +794,32 @@ fn render_navigator_overlay( .max(selected.saturating_sub(body.height.saturating_sub(1) as usize)) .min(selected) .min(max); - let following_siblings = navigator_following_siblings(&rows); - let mut ancestor_siblings = Vec::new(); - let federated = endpoints.len() > 1; + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: max.saturating_sub(scroll), + max_offset_from_bottom: max, + viewport_rows: usize::from(body.height), + }; + let scrollbar = + (max > 0 && body.width > 1).then_some(Rect::new(body.right() - 1, body.y, 1, body.height)); + let row_width = body.width.saturating_sub(u16::from(scrollbar.is_some())); let mut row_hits = Vec::new(); - for (ix, r) in rows.iter().enumerate().take(scroll + body.height as usize) { - ancestor_siblings.truncate(usize::from(r.depth)); - ancestor_siblings.push(following_siblings[ix]); - if ix < scroll { - continue; - } - let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, body.width, 1); + if rows.is_empty() { + put_text( + b, + body.x, + body.y, + body.width, + " No matching agents or terminals", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + } + for (ix, r) in rows + .iter() + .enumerate() + .skip(scroll) + .take(body.height as usize) + { + let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, row_width, 1); row_hits.push((rect, r.target.clone())); let st = if r.stale { Style::default() @@ -821,50 +837,74 @@ fn render_navigator_overlay( .add_modifier(Modifier::BOLD) } else { Style::default() - .fg(if r.current { p.text } else { p.subtext0 }) + .fg( + if matches!(r.target, ClientNavigatorTarget::Machine { .. }) { + p.subtext0 + } else { + p.text + }, + ) .bg(p.panel_bg) }; - b.set_style(rect, st); - let tree = match &r.target { - ClientNavigatorTarget::Machine { .. } => "▾".to_owned(), - ClientNavigatorTarget::Workspace { - endpoint_id, - workspace_id, - } if n - .expanded_workspaces - .contains(&(endpoint_id.clone(), workspace_id.clone())) => - { - if r.depth == 0 { "▾" } else { " ▾" }.to_owned() - } - ClientNavigatorTarget::Workspace { .. } => { - if r.depth == 0 { "▸" } else { " ▸" }.to_owned() - } - ClientNavigatorTarget::Tab { .. } | ClientNavigatorTarget::Pane { .. } => { - // Machines and workspaces keep their existing caret decoration. - // Connected branches begin below each workspace. - let mut prefix = if federated { " " } else { "" }.to_owned(); - for &following in ancestor_siblings - .iter() - .take(usize::from(r.depth)) - .skip(if federated { 2 } else { 1 }) - { - prefix.push_str(if following { "│ " } else { " " }); - } - prefix.push_str(if following_siblings[ix] { - "├──" - } else { - "└──" - }); - prefix - } + let is_pane = matches!(r.target, ClientNavigatorTarget::Pane { .. }); + let connector = if !is_pane { + "" + } else if rows + .get(ix + 1) + .is_some_and(|next| matches!(next.target, ClientNavigatorTarget::Pane { .. })) + { + "├─ " + } else { + "└─ " }; + let padding = u16::from(r.depth.saturating_sub(u8::from(is_pane))) * 2 + 1; + let connector_x = rect.x + padding; + let indent = format!("{:width$}{connector}", "", width = usize::from(padding)); let current = if r.current { "◆ " } else { "" }; let status = r.status.map(status_dot).unwrap_or_default(); let status_separator = if status.is_empty() { "" } else { " " }; - let label = format!(" {tree} {current}{status}{status_separator}{}", r.label); - put_text(b, rect.x, rect.y, rect.width, &label, st); + let label = format!("{indent}{current}{status}{status_separator}{}", r.label); + let st = if r.status.is_none() { + st.add_modifier(Modifier::BOLD) + } else { + st + }; + b.set_style(rect, st); + let columns = if r.status.is_some() { + if rect.width >= 64 { + 24 + } else if rect.width >= 36 { + 12 + } else { + 0 + } + } else { + 0 + }; + put_text( + b, + rect.x, + rect.y, + rect.width.saturating_sub(columns), + &label, + st, + ); + if is_pane { + put_text( + b, + connector_x, + rect.y, + rect.right().saturating_sub(connector_x).min(2), + connector, + if r.stale || ix == selected { + st + } else { + st.fg(p.overlay0) + }, + ); + } if let Some(status) = r.status { - let prefix = format!(" {tree} {current}"); + let prefix = format!("{indent}{current}"); let status_style = if r.stale || ix == selected { st } else { @@ -878,6 +918,35 @@ fn render_navigator_overlay( status_dot(status), status_style, ); + let meta_style = if r.stale || ix == selected { + st + } else { + st.fg(p.overlay0) + }; + if columns > 0 { + put_text( + b, + rect.right() - columns + 1, + rect.y, + 11, + r.agent.as_deref().unwrap_or("terminal"), + meta_style, + ); + } + if columns == 24 { + put_text( + b, + rect.right() - 11, + rect.y, + 11, + if r.agent.is_some() { + status_text(status) + } else { + "shell" + }, + meta_style, + ); + } } let machine_status = match &r.target { ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints @@ -906,7 +975,7 @@ fn render_navigator_overlay( }) }; put_right_text(b, rect, rect.y, &signal, signal_style); - } else if !r.meta.is_empty() { + } else if r.status.is_none() && !r.meta.is_empty() { let label_width = display_width(&label).min(rect.width); let meta = Rect::new( rect.x.saturating_add(label_width).saturating_add(1), @@ -917,16 +986,26 @@ fn render_navigator_overlay( put_right_text(b, meta, rect.y, &r.meta, st) } } - let dy = i.bottom() - 2; + if let Some(track) = scrollbar { + crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐"); + } if let Some(r) = rows.get(selected) { put_text( b, i.x, - dy, + i.bottom() - 3, + i.width, + &format!(" {}", r.detail), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + put_text( + b, + i.x, + i.bottom() - 2, i.width, - &format!(" {} · {}", r.label, r.meta), + &format!(" {}", r.meta), Style::default().fg(p.overlay0).bg(p.panel_bg), - ) + ); } put_text( b, @@ -936,7 +1015,7 @@ fn render_navigator_overlay( if n.search_focused { " search type · move ↑↓/ctrl+n/p · open enter · back esc" } else { - " move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc" + " ↑↓/j/k rows · ←→ workspace · / search · a/b/w/i/d filter · enter open · esc close" }, Style::default().fg(p.overlay0).bg(p.panel_bg), ); @@ -948,6 +1027,8 @@ fn render_navigator_overlay( navigator_popup: q, navigator_search: Rect::new(i.x, i.y, i.width, 1), navigator_rows: row_hits, + navigator_scrollbar: scrollbar.unwrap_or_default(), + navigator_scroll_metrics: Some(metrics), worktree_search: Rect::default(), worktree_rows: Vec::new(), cursor, diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index b975b2f6..7128db57 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -121,6 +121,8 @@ pub(super) struct ShellHitMap { pub(super) navigator_popup: Rect, pub(super) navigator_search: Rect, pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>, + pub(super) navigator_scrollbar: Rect, + pub(super) navigator_scroll_metrics: Option<crate::pane::ScrollMetrics>, pub(super) worktree_search: Rect, pub(super) worktree_rows: Vec<(Rect, usize)>, pub(super) help_popup: Rect, @@ -196,6 +198,9 @@ pub(super) enum ClientChromeDrag { HelpScrollbar { grab_row_offset: u16, }, + NavigatorScrollbar { + grab_row_offset: u16, + }, ProductAnnouncementScrollbar { grab_row_offset: u16, }, @@ -336,10 +341,6 @@ pub(super) enum ClientNavigatorTarget { endpoint_id: ClientEndpointId, workspace_id: String, }, - Tab { - endpoint_id: ClientEndpointId, - tab_id: String, - }, Pane { endpoint_id: ClientEndpointId, pane_id: String, @@ -351,6 +352,8 @@ pub(super) struct ClientNavigatorRow { pub(super) depth: u8, pub(super) label: String, pub(super) meta: String, + pub(super) detail: String, + pub(super) agent: Option<String>, pub(super) status: Option<crate::api::schema::AgentStatus>, pub(super) stale: bool, pub(super) current: bool, @@ -364,7 +367,6 @@ pub(super) struct ClientNavigatorOverlay { pub(super) selected: Option<ClientNavigatorTarget>, pub(super) scroll: usize, pub(super) filter: Option<ClientNavigatorFilter>, - pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>, } #[derive(Debug)] diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index eda4da89..bdd192a6 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -940,7 +940,34 @@ fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() { } #[test] -fn navigator_renders_connected_siblings_and_ancestor_lines() { +fn navigator_workspace_headings_use_the_active_themes_primary_text() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.theme_runtime.auto_switch = false; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + for palette in [ + Palette::catppuccin(), + Palette::catppuccin_latte(), + Palette::terminal(), + ] { + state.config.palette = palette; + let frame = state.compose(106, 30).expect("navigator"); + let (rect, _) = state + .hits + .navigator_rows + .iter() + .find(|(_, target)| matches!(target, ClientNavigatorTarget::Workspace { .. })) + .expect("workspace heading"); + let position = cell_symbol_position(&frame, *rect, "client-shell"); + let buffer = frame.to_ratatui_buffer().expect("buffer"); + assert_eq!(buffer[position].fg, state.config.palette.text); + assert!(buffer[position].modifier.contains(Modifier::BOLD)); + } +} + +#[test] +fn navigator_renders_every_terminal_in_workspace_sections() { let mut snapshot = snapshot(); snapshot.focused_pane_id = None; snapshot.tabs[0].label = "editor".into(); @@ -986,39 +1013,46 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() { state.set_snapshot(Box::new(snapshot)); state.set_pane_surface(surface()); state.open_navigator_overlay(); - let prefixes = |state: &mut ClientShellState, height| { + let visible_rows = |state: &mut ClientShellState, height| { let frame = state.compose(106, height).expect("navigator frame"); state .hits .navigator_rows .iter() .map(|(rect, _)| { - frame.cells[rect.y as usize * frame.width as usize + rect.x as usize + 1..] + frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..] .iter() - .take(6) + .take(rect.width as usize) .map(|cell| cell.symbol.as_str()) .collect::<String>() }) .collect::<Vec<_>>() }; - assert_eq!( - prefixes(&mut state, 30), - [ - "▾ clie", - "├── ed", - "│ ├──", - "│ └──", - "├── no", - "│ └──", - "└── lo", - " └──", - "▾ seco", - "└── la", - " └──" - ] - ); + let visible = visible_rows(&mut state, 30); + assert_eq!(visible.len(), 7); + for (row, prefix) in visible + .iter() + .zip([" client", " ├─ ", " ├─ ", " ├─ ", " └─ ", " second", " └─ "]) + { + assert!( + row.starts_with(prefix), + "{row:?} should start with {prefix:?}" + ); + } + for (row, label) in visible.iter().zip([ + "client-shell", + "editor · agent · 1", + "editor · shell · 2", + "notes", + "logs", + "second", + "agent", + ]) { + assert!(row.contains(label), "{row:?} should contain {label}"); + assert!(!row.contains("/repo")); + assert!(!row.contains("──")); + } - // The editor ancestor is above this viewport; the logs sibling is below it. let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { panic!("expected navigator"); }; @@ -1027,24 +1061,477 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() { endpoint_id: state.active_endpoint_id.clone(), pane_id: "pane_shell".into(), }); - assert_eq!( - prefixes(&mut state, 12), - ["│ ├──", "│ └──", "├── no", "│ └──"] - ); + let visible = visible_rows(&mut state, 11); + assert_eq!(visible.len(), 2); + assert!(visible.iter().any(|row| row.contains("editor · shell · 2"))); + assert!(visible[0].starts_with(" ├─ ")); + assert!(visible[1].starts_with(" ├─ ")); - // Excluded siblings must not leave dangling continuation lines. + // Filtering retains the section and the exact split destination. let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { panic!("expected navigator"); }; - navigator.query = "shell".into(); + navigator.query = "pane_shell".into(); navigator.scroll = 0; - assert_eq!(prefixes(&mut state, 30), ["▾ clie", "└── ed", " └──"]); + let visible = visible_rows(&mut state, 30); + assert_eq!(visible.len(), 2); + assert!(visible[1].starts_with(" └─ ")); + assert!(visible.iter().any(|row| row.contains("editor · shell · 2"))); + assert!(visible.iter().all(|row| !row.contains("second"))); +} + +#[test] +fn navigator_searches_ancestor_context_and_keeps_split_agents_individually_actionable() { + let mut projected = snapshot(); + projected.tabs[0].label = "review".into(); + projected.tabs[0].custom_label = true; + let mut second = projected.panes[0].clone(); + second.pane_id = "pane_2".into(); + second.focused = false; + second.foreground_cwd = Some("/repo/subproject".into()); + projected.panes.push(second); + let first_agent = ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("writer".into()), + display_agent: None, + agent: Some("pi".into()), + title: Some("implementing navigation".into()), + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }; + let mut second_agent = first_agent.clone(); + second_agent.pane_id = "pane_2".into(); + second_agent.name = Some("reviewer".into()); + second_agent.agent = Some("claude".into()); + second_agent.title = Some("checking navigation".into()); + second_agent.agent_status = AgentStatus::Blocked; + second_agent.focused = false; + projected.agents = vec![first_agent, second_agent]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + for (query, filter, expected) in [ + ("", None, vec!["pane_1", "pane_2"]), + ("review", None, vec!["pane_1", "pane_2"]), + ("client-shell", None, vec!["pane_1", "pane_2"]), + ("main", None, vec!["pane_1", "pane_2"]), + ("claude", None, vec!["pane_2"]), + ("checking navigation", None, vec!["pane_2"]), + ("/repo/subproject", None, vec!["pane_2"]), + ( + "review", + Some(ClientNavigatorFilter::Blocked), + vec!["pane_2"], + ), + ("", Some(ClientNavigatorFilter::Working), vec!["pane_1"]), + ("no such agent", None, vec![]), + ] { + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = query.into(); + navigator.filter = filter; + navigator.selected = None; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let pane_ids = rows + .iter() + .filter_map(|row| match &row.target { + ClientNavigatorTarget::Pane { pane_id, .. } => Some(pane_id.as_str()), + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(pane_ids, expected, "query={query:?} filter={filter:?}"); + assert_eq!( + rows.len(), + if expected.is_empty() { + 0 + } else { + expected.len() + 1 + } + ); + if !expected.is_empty() { + let selected = + super::super::aggregate_navigation::navigator_selected_index(&rows, navigator) + .expect("search destination"); + assert!(matches!( + rows[selected].target, + ClientNavigatorTarget::Pane { .. } + )); + } + } let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { - panic!("expected navigator"); + panic!("navigator"); }; navigator.query.clear(); - navigator.expanded_workspaces.clear(); - assert_eq!(prefixes(&mut state, 30), ["▸ clie", "▸ seco"]); + let frame = state.compose(160, 48).expect("navigator"); + assert_eq!(state.hits.navigator_popup.width, 116); + let pane_rows = state + .hits + .navigator_rows + .iter() + .filter(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { .. })) + .collect::<Vec<_>>(); + assert_eq!(pane_rows.len(), 2); + for ((rect, _), (name, kind, status)) in pane_rows.iter().zip([ + ("writer", "pi", "working"), + ("reviewer", "claude", "blocked"), + ]) { + cell_symbol_position(&frame, *rect, name); + cell_symbol_position(&frame, *rect, kind); + cell_symbol_position(&frame, *rect, status); + } + let rect = pane_rows[1].0; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.right() - 1, + row: rect.y, + modifiers: KeyModifiers::empty(), + })]); + assert!( + matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2")) + ); +} + +#[test] +fn navigator_distinguishes_unnamed_terminals_on_numbered_tabs() { + let mut projected = snapshot(); + for (number, label) in [(2, "2"), (3, "logs")] { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{number}"); + tab.number = number; + tab.label = label.into(); + tab.custom_label = number == 3; + tab.focused = false; + let mut pane = projected.panes[0].clone(); + pane.pane_id = format!("pane_{number}"); + pane.tab_id = tab.tab_id.clone(); + pane.focused = false; + projected.tabs.push(tab); + projected.panes.push(pane); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.open_navigator_overlay(); + let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else { + panic!("navigator"); + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let labels = rows + .iter() + .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. })) + .map(|row| row.label.as_str()) + .collect::<Vec<_>>(); + assert_eq!(labels, ["terminal · 1", "terminal · 2", "logs"]); +} + +#[test] +fn navigator_keeps_empty_workspaces_searchable_without_status_filters() { + let mut projected = snapshot(); + projected.tabs.clear(); + projected.panes.clear(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.open_navigator_overlay(); + for (query, filter, expected) in [ + ("", None, true), + ("client-shell", None, true), + ("main", None, true), + ("missing", None, false), + ("main", Some(ClientNavigatorFilter::Idle), false), + ("", Some(ClientNavigatorFilter::Working), false), + ] { + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = query.into(); + navigator.filter = filter; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + assert_eq!( + rows.len(), + usize::from(expected), + "query={query:?}, filter={filter:?}" + ); + let target = + super::super::aggregate_navigation::selected_navigator_target(&rows, navigator); + assert_eq!( + target, + expected.then(|| ClientNavigatorTarget::Workspace { + endpoint_id: ClientEndpointId::Local, + workspace_id: "ws_1".into(), + }) + ); + } +} + +#[test] +fn navigator_horizontal_arrows_jump_sections_but_edit_the_search_cursor() { + let mut projected = snapshot(); + projected.panes[0].label = Some("needle-first".into()); + let mut sibling = projected.panes[0].clone(); + sibling.pane_id = "pane_sibling".into(); + sibling.label = Some("other".into()); + sibling.focused = false; + projected.panes.push(sibling); + let mut empty = projected.workspaces[0].clone(); + empty.workspace_id = "ws_empty".into(); + empty.label = "empty".into(); + empty.focused = false; + projected.workspaces.push(empty); + let mut last = projected.workspaces[0].clone(); + last.workspace_id = "ws_last".into(); + last.label = "last".into(); + last.active_tab_id = "tab_last".into(); + last.focused = false; + let mut tab = projected.tabs[0].clone(); + tab.workspace_id = last.workspace_id.clone(); + tab.tab_id = last.active_tab_id.clone(); + tab.focused = false; + for (id, label) in [ + ("pane_last", "needle-last"), + ("pane_last_sibling", "other-last"), + ] { + let mut pane = projected.panes[0].clone(); + pane.pane_id = id.into(); + pane.label = Some(label.into()); + pane.workspace_id = last.workspace_id.clone(); + pane.tab_id = tab.tab_id.clone(); + pane.focused = false; + projected.panes.push(pane); + } + projected.workspaces.push(last); + projected.tabs.push(tab); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + let press = |state: &mut ClientShellState, code| { + let outcome = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(code, KeyModifiers::empty()), + )]); + assert!(outcome.actions.is_empty()); + }; + let selected = |state: &ClientShellState| { + let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else { + panic!("navigator"); + }; + navigator.selected.clone() + }; + let target = |id: &str| { + Some(ClientNavigatorTarget::Pane { + endpoint_id: ClientEndpointId::Local, + pane_id: id.into(), + }) + }; + press(&mut state, KeyCode::Left); + assert_eq!(selected(&state), target("pane_1")); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_last")); + press(&mut state, KeyCode::Down); + assert_eq!(selected(&state), target("pane_last_sibling")); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_last_sibling")); + press(&mut state, KeyCode::Left); + assert_eq!(selected(&state), target("pane_1")); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = "needle".into(); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_last")); + press(&mut state, KeyCode::Left); + assert_eq!(selected(&state), target("pane_1")); + press(&mut state, KeyCode::Char('/')); + press(&mut state, KeyCode::Left); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), target("pane_1")); + press(&mut state, KeyCode::Left); + press(&mut state, KeyCode::Char('X')); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + assert_eq!(navigator.query.as_str(), "needlXe"); + navigator.search_focused = false; + navigator.selected = None; + press(&mut state, KeyCode::Left); + press(&mut state, KeyCode::Right); + assert_eq!(selected(&state), None); +} + +#[test] +fn navigator_scrollbar_click_and_drag_scroll_without_opening_a_destination() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + state.compose(106, 24).expect("small navigator"); + assert!(state.hits.navigator_scrollbar.is_empty()); + + let mut projected = snapshot(); + for index in 2..=60 { + let mut pane = projected.panes[0].clone(); + pane.pane_id = format!("pane_{index}"); + pane.label = Some(format!("agent {index}")); + pane.focused = false; + projected.panes.push(pane); + } + state.set_snapshot(Box::new(projected)); + let frame = state.compose(106, 24).expect("overflowing navigator"); + let track = state.hits.navigator_scrollbar; + let metrics = state.hits.navigator_scroll_metrics.expect("scroll metrics"); + assert!(!track.is_empty()); + assert_eq!(metrics.offset_from_bottom, metrics.max_offset_from_bottom); + assert!(track.y > state.hits.navigator_search.y); + assert!(track.bottom() < state.hits.navigator_popup.bottom() - 3); + assert!(state + .hits + .navigator_rows + .iter() + .all(|(rect, _)| rect.right() == track.x)); + let buffer = frame.to_ratatui_buffer().expect("buffer"); + assert_eq!(buffer[(track.x, track.y)].fg, state.config.palette.overlay1); + assert_eq!( + buffer[(track.x, track.bottom() - 1)].fg, + state.config.palette.overlay0 + ); + let mouse = |state: &mut ClientShellState, kind, row| { + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind, + column: track.x, + row, + modifiers: KeyModifiers::empty(), + })]); + assert!(outcome.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::Navigator(_)) + )); + }; + mouse( + &mut state, + MouseEventKind::Down(MouseButton::Left), + track.bottom() - 1, + ); + state.compose(106, 24).expect("track jump"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + 0 + ); + assert!(state.hits.navigator_rows.iter().any(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { pane_id, .. } if pane_id == "pane_60"))); + mouse(&mut state, MouseEventKind::Down(MouseButton::Left), track.y); + state.compose(106, 24).expect("jump back to top"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + metrics.max_offset_from_bottom + ); + let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("thumb"); + let grab = thumb.len - 1; + mouse( + &mut state, + MouseEventKind::Down(MouseButton::Left), + thumb.top + grab, + ); + assert!( + matches!(state.chrome_drag, Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) if grab_row_offset == grab) + ); + mouse( + &mut state, + MouseEventKind::Drag(MouseButton::Left), + thumb.top + grab, + ); + state.compose(106, 24).expect("grab does not move viewport"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + metrics.max_offset_from_bottom + ); + mouse( + &mut state, + MouseEventKind::Drag(MouseButton::Left), + track.bottom() + 5, + ); + state.compose(106, 24).expect("drag to bottom"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + 0 + ); + mouse( + &mut state, + MouseEventKind::Up(MouseButton::Left), + track.bottom() + 5, + ); + assert!(state.chrome_drag.is_none()); + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Up, + KeyModifiers::empty(), + ))]); + state.compose(106, 24).expect("keyboard resumes after drag"); + assert_eq!( + state + .hits + .navigator_scroll_metrics + .expect("metrics") + .offset_from_bottom, + 1 + ); + + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.query = "pane_60".into(); + navigator.selected = None; + state.compose(106, 24).expect("filtered navigator"); + assert!(state.hits.navigator_scrollbar.is_empty()); + assert_eq!(state.hits.navigator_rows.len(), 2); + state.compose(106, 90).expect("tall filtered navigator"); + assert!(state.hits.navigator_scrollbar.is_empty()); +} + +#[test] +fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + for (width, height) in [(24, 12), (50, 24), (106, 30)] { + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + navigator.search_focused = true; + navigator.query = "界".repeat(100).as_str().into(); + let frame = state.compose(width, height).expect("navigator frame"); + let popup = state.hits.navigator_popup; + let cursor = frame.cursor.expect("search cursor"); + assert!(super::super::contains(popup, (cursor.x, cursor.y))); + assert!(state.hits.navigator_rows.is_empty()); + assert!(popup.right() <= width && popup.bottom() <= height); + } } #[test] @@ -1098,7 +1585,8 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() { .collect::<Vec<_>>() .join("\n"); assert!(navigator_text.contains("client-shell")); - assert!(navigator_text.contains("pane 1")); + assert!(navigator_text.contains("terminal")); + assert!(!navigator_text.contains("pane 1")); let search = state.hits.navigator_search; let focus_search = diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index e4914971..4434e119 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -2056,18 +2056,16 @@ fn navigator_uses_machine_parents_only_for_federated_clients() { assert!(rows.iter().all(|row| match row.target { ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(), ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(), - ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(), - ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(), + ClientNavigatorTarget::Pane { .. } => row.depth == 2 && row.status.is_some(), })); assert_eq!(rows.iter().filter(|row| row.current).count(), 1); let frame = state.compose(106, 30).expect("federated navigator"); for (rect, target) in &state.hits.navigator_rows { let expected = match target { - ClientNavigatorTarget::Machine { .. } => " ▾ ", - ClientNavigatorTarget::Workspace { .. } => " ▾ ", - ClientNavigatorTarget::Tab { .. } => " └── ", - ClientNavigatorTarget::Pane { .. } => " └── ", + ClientNavigatorTarget::Machine { .. } => " ", + ClientNavigatorTarget::Workspace { .. } => " ", + ClientNavigatorTarget::Pane { .. } => " └─ ", }; let prefix = frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..] .iter() @@ -2105,8 +2103,7 @@ fn navigator_uses_machine_parents_only_for_federated_clients() { .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. }))); assert!(rows.iter().all(|row| match row.target { ClientNavigatorTarget::Workspace { .. } => row.depth == 0, - ClientNavigatorTarget::Tab { .. } => row.depth == 1, - ClientNavigatorTarget::Pane { .. } => row.depth == 2, + ClientNavigatorTarget::Pane { .. } => row.depth == 1, ClientNavigatorTarget::Machine { .. } => false, })); } @@ -2143,10 +2140,6 @@ fn navigator_keeps_saved_machine_visible_before_metadata_arrives() { endpoint_id: target, .. } - | ClientNavigatorTarget::Tab { - endpoint_id: target, - .. - } | ClientNavigatorTarget::Pane { endpoint_id: target, .. @@ -2497,7 +2490,32 @@ fn collapsed_aggregate_workspace_status_uses_its_status_color() { } #[test] -fn navigator_foreign_tab_selection_keeps_the_tab_target() { +fn navigator_workspace_arrows_cross_machine_headings_without_activating_them() { + let (mut state, endpoint_id) = state_with_remote(); + state.open_navigator_overlay(); + for (key, expected_endpoint) in [ + (KeyCode::Right, endpoint_id), + (KeyCode::Left, ClientEndpointId::Local), + ] { + let outcome = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(key, KeyModifiers::empty()), + )]); + assert!(outcome.actions.is_empty()); + let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else { + panic!("navigator"); + }; + assert_eq!( + navigator.selected, + Some(ClientNavigatorTarget::Pane { + endpoint_id: expected_endpoint, + pane_id: "pane_1".into(), + }) + ); + } +} + +#[test] +fn navigator_foreign_workspace_heading_keeps_the_workspace_target() { let (mut state, endpoint_id) = state_with_remote(); state.open_navigator_overlay(); let selected = { @@ -2510,15 +2528,14 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() { .find(|row| { matches!( &row.target, - ClientNavigatorTarget::Tab { + ClientNavigatorTarget::Workspace { endpoint_id: target_endpoint, - tab_id, - .. - } if target_endpoint == &endpoint_id && tab_id == "tab_1" + workspace_id, + } if target_endpoint == &endpoint_id && workspace_id == "ws_1" ) }) .map(|row| row.target.clone()) - .expect("remote tab row") + .expect("remote workspace heading") }; if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { navigator.selected = Some(selected); @@ -2531,7 +2548,7 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() { outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id: activated, - target: Some(ClientEndpointFocusTarget::Tab(tab_id)), - }] if activated == &endpoint_id && tab_id == "tab_1" + target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)), + }] if activated == &endpoint_id && workspace_id == "ws_1" )); } diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs index c5fa1f4a..98fc62fa 100644 --- a/src/client/shell/tests/graphics.rs +++ b/src/client/shell/tests/graphics.rs @@ -230,7 +230,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { selected: None, scroll: 0, filter: None, - expanded_workspaces: HashSet::new(), }), ClientShellOverlay::WorktreeCreate(ClientWorktreeCreateOverlay { source_workspace_id: "ws_1".into(), diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 7f3ea137..c9f02278 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -83,7 +83,6 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { selected: None, scroll: 0, filter: None, - expanded_workspaces: HashSet::new(), })); assert!(!state.modal_paste_target_active()); if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { From 856b64b9bfd41b9d3d82e2375ed5b4bf941fda28 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Sat, 19 Sep 2026 20:21:46 +0200 Subject: [PATCH 23/99] fix: stabilize windows cursor redraws and settle deadlines (#4389) * fix: prevent cursor jumps during synchronized redraws refs #4303 * fix: settle cursor positions across delayed windows redraws refs #4303 * fix(windows): hold jump-shaped cursor moves through the max window * fix: bound windows cursor settling and repaint at its deadline refs #4303 --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com> Co-authored-by: JJLiebig <jj@liebig.gg> Co-authored-by: Axel Karlsson <axelalbertkarlsson@outlook.com> --- src/pane/cursor.rs | 225 ++++++++++++++++++++++++++++++++++++++----- src/pane/terminal.rs | 139 ++++++++++++++++++++++---- 2 files changed, 321 insertions(+), 43 deletions(-) diff --git a/src/pane/cursor.rs b/src/pane/cursor.rs index c72f697a..13ac61f0 100644 --- a/src/pane/cursor.rs +++ b/src/pane/cursor.rs @@ -90,55 +90,59 @@ pub(crate) struct CursorPositionSettleState { settled: Option<TerminalCursorState>, candidate: Option<TerminalCursorState>, pending_since: Option<Instant>, + candidate_since: Option<Instant>, + /// True when this candidate jumped away from the settled caret (a different + /// row, or a large same-row column move). + /// + /// Those are the shape of a redraw parking the cursor on a temporary cell, + /// so they are held for the max window before being shown. Ordinary caret + /// steps are small and same-row, and settle on the normal window. + candidate_jump: bool, } impl CursorPositionSettleState { pub(crate) fn observe(&mut self, current: Option<TerminalCursorState>, now: Instant) { + // A candidate that stayed quiet for its hold window is real, so preserve + // it before considering the first (possibly temporary) position of a + // later redraw. + if let (Some(candidate), Some(since)) = (self.candidate, self.candidate_since) { + if now.duration_since(since) >= self.candidate_hold() { + self.settle(Some(candidate)); + } + } let Some(current) = current else { - self.settled = None; - self.candidate = None; - self.pending_since = None; + self.settle(None); return; }; if !current.visible { - self.settled = Some(current); - self.candidate = None; - self.pending_since = None; + self.settle(Some(current)); return; } let Some(settled) = self.settled else { - self.settled = Some(current); - self.candidate = None; - self.pending_since = None; + self.settle(Some(current)); return; }; if same_cursor_position(settled, current) && settled.visible { - self.settled = Some(current); - self.candidate = None; - self.pending_since = None; + self.settle(Some(current)); return; } let Some(candidate) = self.candidate else { self.candidate = Some(current); self.pending_since = Some(now); + self.candidate_since = Some(now); + self.candidate_jump = is_jump(settled, current); return; }; let pending_since = self.pending_since.unwrap_or(now); if now.duration_since(pending_since) >= CURSOR_POSITION_MAX_HOLD { - self.settled = Some(current); - self.candidate = None; - self.pending_since = None; - } else if same_cursor_position(candidate, current) { - if now.duration_since(pending_since) >= CURSOR_POSITION_SETTLE { - self.settled = Some(current); - self.candidate = None; - self.pending_since = None; - } else { - self.candidate = Some(current); - } + self.settle(Some(current)); } else { + if !same_cursor_position(candidate, current) { + self.candidate_since = Some(now); + self.candidate_jump = is_jump(settled, current); + } self.candidate = Some(current); } } @@ -152,8 +156,14 @@ impl CursorPositionSettleState { let Some(candidate) = self.candidate else { return Some(current); }; + let candidate_since = self.candidate_since.unwrap_or(now); let pending_since = self.pending_since.unwrap_or(now); - if now.duration_since(pending_since) >= CURSOR_POSITION_SETTLE { + // A jump-shaped candidate is treated as a redraw park until proven + // otherwise, so it waits for the max window. An ordinary caret step only + // waits the normal settle window; the max hold bounds either case. + if now.duration_since(candidate_since) >= self.candidate_hold() + || now.duration_since(pending_since) >= CURSOR_POSITION_MAX_HOLD + { return Some(TerminalCursorState { visible: current.visible && candidate.visible, shape: current.shape, @@ -176,12 +186,37 @@ impl CursorPositionSettleState { pub(crate) fn pending(&self) -> bool { self.candidate.is_some() } + + pub(crate) fn render_delay(&self) -> Option<Duration> { + self.pending().then(|| self.candidate_hold()) + } + + fn candidate_hold(&self) -> Duration { + if self.candidate_jump { + CURSOR_POSITION_MAX_HOLD + } else { + CURSOR_POSITION_SETTLE + } + } + + fn settle(&mut self, cursor: Option<TerminalCursorState>) { + *self = Self { + settled: cursor, + ..Self::default() + }; + } } fn same_cursor_position(left: TerminalCursorState, right: TerminalCursorState) -> bool { left.x == right.x && left.y == right.y } +/// A cursor move that changes row, or jumps more than a couple of columns, is +/// the shape of a redraw parking the cursor rather than an ordinary caret step. +fn is_jump(settled: TerminalCursorState, current: TerminalCursorState) -> bool { + current.y != settled.y || current.x.abs_diff(settled.x) > 2 +} + #[cfg(test)] mod tests { use super::*; @@ -226,12 +261,61 @@ mod tests { assert_eq!((reported.x, reported.y), (2, 0)); } + #[test] + fn cursor_settle_keeps_previous_caret_during_next_system_conpty_redraw() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + let caret = cursor(2, 12, true, 0); + let typed_caret = cursor(3, 12, true, 0); + let repair = cursor(0, 10, true, 0); + settle.observe(Some(caret), now); + settle.observe(Some(typed_caret), now + Duration::from_millis(1)); + + // System ConPTY closes the next frame at the repair cell, then emits + // the caret restoration separately about 10 ms later. + settle.observe(Some(repair), now + Duration::from_millis(160)); + assert_eq!( + settle.reported_cursor(Some(repair), now + Duration::from_millis(161)), + Some(typed_caret) + ); + settle.observe(Some(typed_caret), now + Duration::from_millis(170)); + assert_eq!( + settle.reported_cursor(Some(typed_caret), now + Duration::from_millis(171)), + Some(typed_caret) + ); + } + + #[test] + fn cursor_settle_restarts_quiet_window_when_candidate_moves() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + let initial = cursor(1, 0, true, 0); + let latest = cursor(3, 0, true, 0); + settle.observe(Some(initial), now); + settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1)); + settle.observe(Some(latest), now + Duration::from_millis(19)); + assert_eq!( + settle.reported_cursor(Some(latest), now + Duration::from_millis(22)), + Some(initial) + ); + assert_eq!( + settle.reported_cursor(Some(latest), now + Duration::from_millis(39)), + Some(latest) + ); + } + #[test] fn cursor_settle_caps_continuous_position_changes_from_first_pending_time() { let now = Instant::now(); let mut settle = CursorPositionSettleState::default(); settle.observe(Some(cursor(1, 0, true, 0)), now); settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1)); + for ms in (11..=91).step_by(10) { + settle.observe( + Some(cursor(ms as u16, 0, true, 0)), + now + Duration::from_millis(ms), + ); + } settle.observe( Some(cursor(3, 0, true, 0)), now + CURSOR_POSITION_MAX_HOLD + Duration::from_millis(1), @@ -247,6 +331,57 @@ mod tests { ); } + #[test] + fn cursor_settle_caps_hold_even_without_another_observation() { + let now = Instant::now(); + for step in [10, 30] { + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(cursor(0, 0, true, 0)), now); + for ms in (1..=91).step_by(step) { + settle.observe( + Some(cursor(ms as u16, 1, true, 0)), + now + Duration::from_millis(ms), + ); + } + assert_eq!( + settle.reported_cursor( + Some(cursor(91, 1, true, 0)), + now + Duration::from_millis(101) + ), + Some(cursor(91, 1, true, 0)) + ); + } + } + + #[test] + fn cursor_settle_repeated_position_does_not_restart_quiet_window() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + let next = cursor(2, 0, true, 0); + settle.observe(Some(cursor(1, 0, true, 0)), now); + settle.observe(Some(next), now + Duration::from_millis(1)); + settle.observe(Some(next), now + Duration::from_millis(19)); + assert_eq!( + settle.reported_cursor(Some(next), now + Duration::from_millis(21)), + Some(next) + ); + } + + #[test] + fn cursor_settle_repeated_jump_position_does_not_starve() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + let next = cursor(2, 1, true, 0); + settle.observe(Some(cursor(2, 0, true, 0)), now); + for ms in [1, 31, 61, 91] { + settle.observe(Some(next), now + Duration::from_millis(ms)); + } + assert_eq!( + settle.reported_cursor(Some(next), now + Duration::from_millis(101)), + Some(next) + ); + } + #[test] fn cursor_settle_keeps_render_read_pure() { let now = Instant::now(); @@ -312,4 +447,46 @@ mod tests { Some(cursor(1, 0, false, 0)) ); } + + #[test] + fn cursor_settle_does_not_publish_a_jump_before_the_max_hold() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + let caret = cursor(2, 26, true, 0); + let park = cursor(0, 25, true, 0); + settle.observe(Some(caret), now); + + // A transition redraw parks the cursor on a cell above the composer and + // restores the caret in a later write, more than one settle window away. + // The park must not be published in the meantime. + settle.observe(Some(park), now + Duration::from_millis(1)); + assert_eq!( + settle.reported_cursor( + Some(park), + now + CURSOR_POSITION_SETTLE + Duration::from_millis(1) + ), + Some(caret) + ); + assert_eq!( + settle.reported_cursor(Some(park), now + Duration::from_millis(70)), + Some(caret) + ); + + // An ordinary same-row caret step still settles on the normal window. + settle.observe( + Some(cursor(3, 26, true, 0)), + now + Duration::from_millis(80), + ); + settle.observe( + Some(cursor(4, 26, true, 0)), + now + Duration::from_millis(81), + ); + assert_eq!( + settle.reported_cursor( + Some(cursor(4, 26, true, 0)), + now + CURSOR_POSITION_SETTLE + Duration::from_millis(82) + ), + Some(cursor(4, 26, true, 0)) + ); + } } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 4d7f07b1..c30ef415 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -21,7 +21,9 @@ mod migration_tests; #[cfg(windows)] mod windows_recent_fallback; -use super::cursor::{CursorPositionSettleState, DecscusrTracker, CURSOR_POSITION_SETTLE}; +#[cfg(test)] +use super::cursor::CURSOR_POSITION_SETTLE; +use super::cursor::{CursorPositionSettleState, DecscusrTracker}; use super::{ input::{ ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal, @@ -1445,7 +1447,8 @@ impl GhosttyPaneTerminal { .terminal .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) .unwrap_or(false); - if CURSOR_POSITION_SETTLE_ENABLED { + // Intermediate synchronized-frame positions must not become settled cursors. + if CURSOR_POSITION_SETTLE_ENABLED && !synchronized_output { let cursor_started = crate::render_prof::timer(); let cursor_after_write = current_cursor_state(&mut core); crate::render_prof::duration_since("pty.cursor_state_update", cursor_started); @@ -1463,7 +1466,7 @@ impl GhosttyPaneTerminal { let render_delay = render_delay_after_pty_write( synchronized_output, has_kitty_graphics_sequence, - cursor_position_settle_pending(&core), + core.cursor_settle_state.render_delay(), CURSOR_POSITION_SETTLE_ENABLED, ); if request_render { @@ -2476,10 +2479,6 @@ fn encoded_key_preserves_event_kind( }) } -fn cursor_position_settle_pending(core: &GhosttyPaneCore) -> bool { - core.cursor_settle_state.pending() -} - fn effective_cursor_state( core: &mut GhosttyPaneCore, current: Option<TerminalCursorState>, @@ -2494,17 +2493,16 @@ fn effective_cursor_state( fn render_delay_after_pty_write( synchronized_output: bool, has_kitty_graphics_sequence: bool, - cursor_position_settle_pending: bool, + cursor_position_settle_delay: Option<Duration>, cursor_position_settle_enabled: bool, ) -> Option<Duration> { if synchronized_output { None - } else if has_kitty_graphics_sequence { - Some(KITTY_GRAPHICS_REDRAW_SETTLE) - } else if cursor_position_settle_enabled && cursor_position_settle_pending { - Some(CURSOR_POSITION_SETTLE) } else { - None + let cursor_delay = cursor_position_settle_enabled + .then_some(cursor_position_settle_delay) + .flatten(); + cursor_delay.max(has_kitty_graphics_sequence.then_some(KITTY_GRAPHICS_REDRAW_SETTLE)) } } @@ -4374,12 +4372,109 @@ mod tests { let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[6;21H", &tx); - assert_eq!(result.render_delay, Some(CURSOR_POSITION_SETTLE)); + assert_eq!(result.render_delay, Some(Duration::from_millis(100))); assert_eq!( pane.cursor_state() .map(|cursor| (cursor.x, cursor.y, cursor.visible)), Some((1, 0, true)) ); + // If output stops here, the scheduled repaint must be late enough to + // publish this cursor without relying on an unrelated later redraw. + let mut core = pane.core.lock().unwrap(); + let current = current_cursor_state(&mut core); + assert_eq!( + core.cursor_settle_state + .reported_cursor(current, Instant::now() + result.render_delay.unwrap()), + current + ); + } + + #[test] + #[cfg(windows)] + fn cursor_settle_ignores_intermediate_synchronized_frame_positions() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.process_pty_bytes(pane_id, 0, b"\x1b[15;4H", &tx); + + let previous = TerminalCursorState { + x: 3, + y: 14, + visible: true, + shape: 0, + }; + { + let mut core = pane.core.lock().unwrap(); + let now = Instant::now(); + core.cursor_settle_state = CursorPositionSettleState::default(); + core.cursor_settle_state.observe( + Some(TerminalCursorState { x: 2, ..previous }), + now - Duration::from_millis(300), + ); + // Seed a pending hold whose deadline has passed, without wall-clock sleeps. + core.cursor_settle_state + .observe(Some(previous), now - Duration::from_millis(200)); + } + + for bytes in [ + b"\x1b[?2026h\x1b[15;4Hx\x1b[13;1H".as_slice(), + b"\x1b[0 q\x1b[13;1H \x1b[15;5H", + b"\x1b[?25h", + ] { + let result = pane.process_pty_bytes(pane_id, 0, bytes, &tx); + assert!(!result.request_render); + assert_eq!(result.render_delay, None); + assert_eq!(pane.cursor_state(), Some(previous)); + assert!(pane.core.lock().unwrap().cursor_settle_state.pending()); + } + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[?2026l", &tx); + assert!(result.request_render); + assert_eq!(result.render_delay, Some(CURSOR_POSITION_SETTLE)); + assert!(pane.core.lock().unwrap().cursor_settle_state.pending()); + assert_eq!(pane.cursor_state(), Some(previous)); + + // ConPTY may restore the real caret after the synchronized frame closes. + let mut core = pane.core.lock().unwrap(); + let current = current_cursor_state(&mut core); + assert_eq!( + core.cursor_settle_state + .reported_cursor(current, Instant::now() + CURSOR_POSITION_SETTLE), + Some(TerminalCursorState { x: 4, ..previous }) + ); + } + + #[test] + #[cfg(windows)] + fn cursor_settle_preserves_final_visibility_and_shape_across_split_sync_sequences() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.process_pty_bytes(pane_id, 0, b"\x1b[15;4H", &tx); + + for bytes in [ + b"\x1b[?202".as_slice(), + b"6h\x1b[13;1H", + b"\x1b[6 q\x1b[15;5H\x1b[?25l\x1b[?20", + ] { + pane.process_pty_bytes(pane_id, 0, bytes, &tx); + assert!(!pane.core.lock().unwrap().cursor_settle_state.pending()); + } + + let result = pane.process_pty_bytes(pane_id, 0, b"26l", &tx); + assert!(result.request_render); + assert_eq!(result.render_delay, None); + assert_eq!( + pane.cursor_state(), + Some(TerminalCursorState { + x: 4, + y: 14, + visible: false, + shape: 6, + }) + ); } #[test] @@ -4403,19 +4498,25 @@ mod tests { #[test] fn cursor_settle_policy_controls_render_delay() { + let delay = Some(CURSOR_POSITION_SETTLE); assert_eq!( - render_delay_after_pty_write(false, false, true, true), - Some(CURSOR_POSITION_SETTLE) + render_delay_after_pty_write(false, false, delay, true), + delay ); assert_eq!( - render_delay_after_pty_write(false, false, true, false), + render_delay_after_pty_write(false, false, delay, false), None ); assert_eq!( - render_delay_after_pty_write(false, true, true, false), + render_delay_after_pty_write(false, true, delay, false), Some(KITTY_GRAPHICS_REDRAW_SETTLE) ); - assert_eq!(render_delay_after_pty_write(true, false, true, true), None); + assert_eq!(render_delay_after_pty_write(true, false, delay, true), None); + let jump_delay = Some(Duration::from_millis(100)); + assert_eq!( + render_delay_after_pty_write(false, true, jump_delay, true), + jump_delay + ); } #[test] From 9d29441a84858780f80ff1bc35ce9c0c5f43b0c9 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Sun, 20 Sep 2026 00:21:32 +0200 Subject: [PATCH 24/99] test(windows): distinguish clipboard paste origin in input gauntlet (#4394) * test(windows): distinguish clipboard paste origin in input gauntlet refs #4314 * test(windows): correct gauntlet paste-origin evidence refs #4314 --- scripts/test_windows_input.ps1 | 18 +++++++++++++ scripts/test_windows_input.py | 39 ++++++++++++++++++++++++++- scripts/windows_input/Common.ps1 | 6 +++++ scripts/windows_input/README.md | 5 ++++ scripts/windows_input/report.py | 46 +++++++++++++++++++++++++++++--- 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index 7396bffa..364ce414 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -111,6 +111,18 @@ function Outer-State($Plan) { $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++) { @@ -351,6 +363,12 @@ try { $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 diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py index 38868843..a844ea62 100644 --- a/scripts/test_windows_input.py +++ b/scripts/test_windows_input.py @@ -1,7 +1,7 @@ """Portable tests of the gauntlet's oracle, not Windows input qualification.""" import copy import unittest -from scripts.windows_input.report import catalogue, channel_identity_errors, herdr_protocol_label, known_host_gap, qualification_matrix, summarize, verdict +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): @@ -95,12 +95,49 @@ def test_remote_clipboard_image_requires_empty_host_paste_and_staged_png_path(se 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" diff --git a/scripts/windows_input/Common.ps1 b/scripts/windows_input/Common.ps1 index d944e87b..1fd4943f 100644 --- a/scripts/windows_input/Common.ps1 +++ b/scripts/windows_input/Common.ps1 @@ -41,6 +41,12 @@ function New-GauntletProcess($Exe, $Arguments, $Plan, [switch] $Capture) { $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' } diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index 5f8f463f..edebc8fc 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -132,6 +132,11 @@ Every run needs a **new** output directory. By default it is 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; 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 diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index d6c20803..cf37f30e 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -126,6 +126,32 @@ def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases) +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"}: @@ -212,10 +238,13 @@ def geometry(name): 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": - return (("pass", "Terminal emitted an empty bracketed paste for image-only clipboard") - if raw == b"\x1b[200~\x1b[201~" else - ("fail", "Terminal did not emit an empty bracketed paste for image-only clipboard")) + 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~"): @@ -227,6 +256,14 @@ def geometry(name): 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")) @@ -302,6 +339,9 @@ def summarize(document): 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"): From d59d0603d53bb88c5320ea508a4fb9858b61af68 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Sun, 20 Sep 2026 03:33:51 +0200 Subject: [PATCH 25/99] fix: recover the cursor during animated redraws (#4404) refs #4303 --- src/pane/cursor.rs | 109 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 89 insertions(+), 20 deletions(-) diff --git a/src/pane/cursor.rs b/src/pane/cursor.rs index 13ac61f0..a7886259 100644 --- a/src/pane/cursor.rs +++ b/src/pane/cursor.rs @@ -102,10 +102,22 @@ pub(crate) struct CursorPositionSettleState { impl CursorPositionSettleState { pub(crate) fn observe(&mut self, current: Option<TerminalCursorState>, now: Instant) { - // A candidate that stayed quiet for its hold window is real, so preserve - // it before considering the first (possibly temporary) position of a - // later redraw. + // A return to the caret's position or row ends the redraw hold, even + // when typing advanced its column. Otherwise the accumulated typing + // deadline can settle a later repair cell and anchor redraws there. if let (Some(candidate), Some(since)) = (self.candidate, self.candidate_since) { + let restored = self.settled.zip(current).is_some_and(|(settled, current)| { + settled.visible + && (same_cursor_position(settled, current) + || (candidate.y != settled.y + && current.y == settled.y + && now.duration_since(since) < self.candidate_hold())) + }); + if restored { + self.settle(current); + return; + } + // Preserve an eligible caret before a later redraw moves it away. if now.duration_since(since) >= self.candidate_hold() { self.settle(Some(candidate)); } @@ -264,25 +276,82 @@ mod tests { #[test] fn cursor_settle_keeps_previous_caret_during_next_system_conpty_redraw() { let now = Instant::now(); - let mut settle = CursorPositionSettleState::default(); let caret = cursor(2, 12, true, 0); - let typed_caret = cursor(3, 12, true, 0); - let repair = cursor(0, 10, true, 0); - settle.observe(Some(caret), now); - settle.observe(Some(typed_caret), now + Duration::from_millis(1)); + for (next_caret, repair) in [ + (cursor(3, 12, true, 0), cursor(0, 10, true, 0)), + (cursor(20, 12, true, 0), cursor(0, 10, true, 0)), + (cursor(2, 13, true, 0), cursor(0, 10, true, 0)), + (cursor(2, 13, true, 0), cursor(0, 12, true, 0)), + ] { + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(caret), now); + settle.observe(Some(next_caret), now + Duration::from_millis(1)); - // System ConPTY closes the next frame at the repair cell, then emits - // the caret restoration separately about 10 ms later. - settle.observe(Some(repair), now + Duration::from_millis(160)); - assert_eq!( - settle.reported_cursor(Some(repair), now + Duration::from_millis(161)), - Some(typed_caret) - ); - settle.observe(Some(typed_caret), now + Duration::from_millis(170)); - assert_eq!( - settle.reported_cursor(Some(typed_caret), now + Duration::from_millis(171)), - Some(typed_caret) - ); + // Preserve real typing, Home/End, and row moves before the next + // ConPTY redraw parks briefly at its repair cell. + settle.observe(Some(repair), now + Duration::from_millis(160)); + assert_eq!( + settle.reported_cursor(Some(repair), now + Duration::from_millis(161)), + Some(next_caret) + ); + settle.observe(Some(next_caret), now + Duration::from_millis(170)); + assert_eq!( + settle.reported_cursor(Some(next_caret), now + Duration::from_millis(171)), + Some(next_caret) + ); + } + } + + #[test] + fn cursor_settle_recovers_from_a_late_restore_during_continuous_redraws() { + let now = Instant::now(); + let caret = cursor(6, 9, true, 0); + let park = cursor(0, 7, true, 0); + for (restored, first_restore) in [ + (caret, 161), + (cursor(7, 9, true, 0), 71), + (cursor(30, 9, true, 0), 71), + ] { + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(caret), now); + settle.observe(Some(park), now + Duration::from_millis(1)); + + // Exact restoration can miss the max hold. A changed column only + // restores the old row while the new row is still provisional. + for ms in (first_restore..first_restore + 1800).step_by(30) { + let restored_at = now + Duration::from_millis(ms); + settle.observe(Some(restored), restored_at); + assert_eq!( + settle.reported_cursor(Some(restored), restored_at), + Some(restored), + "the repair cell must not become the anchor for later redraws" + ); + settle.observe(Some(park), restored_at + Duration::from_millis(20)); + } + } + } + + #[test] + fn cursor_settle_keeps_typing_deadlines_from_adopting_redraw_positions() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + let park = cursor(0, 37, true, 0); + settle.observe(Some(cursor(3, 39, true, 0)), now); + settle.observe(Some(cursor(4, 39, true, 0)), now + Duration::from_millis(1)); + + // Fast typing advances the caret while redraws briefly visit another + // row. A burst's max deadline can fall on one of those repair writes. + for step in 0..60 { + let parked_at = now + Duration::from_millis(11 + step * 30); + settle.observe(Some(park), parked_at); + assert_eq!( + settle.reported_cursor(Some(park), parked_at).unwrap().y, + 39, + "a fresh repair position must not inherit the typing deadline" + ); + let caret = cursor(6 + step as u16 * 2, 39, true, 0); + settle.observe(Some(caret), parked_at + Duration::from_millis(10)); + } } #[test] From a3a1c94ed54e8a65d929528336d69c7e537ed2ef Mon Sep 17 00:00:00 2001 From: Vinay Shah <vinayshah2006@gmail.com> Date: Sun, 20 Sep 2026 04:55:41 -0700 Subject: [PATCH 26/99] fix: detect kiro status from live controls and osc signals (#4372) * fix: detect kiro status from live controls and osc signals refs #4203 * fix: handle variable-height kiro approval panels refs #4203 --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- distribution/agent-detection/kiro.toml | 110 ++++++++++++++++--------- src/detect/manifests/kiro.toml | 110 ++++++++++++++++--------- 2 files changed, 144 insertions(+), 76 deletions(-) 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/src/detect/manifests/kiro.toml b/src/detect/manifests/kiro.toml index 59ac0a06..9d50f932 100644 --- a/src/detect/manifests/kiro.toml +++ b/src/detect/manifests/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;?$'] From 29f9f4056f344af60f411004fc89c7eb5f357c48 Mon Sep 17 00:00:00 2001 From: Ivan <unmanbearpig@gmail.com> Date: Sun, 20 Sep 2026 09:23:58 -0300 Subject: [PATCH 27/99] fix: detect codex status with custom interrupt keys (#4196) * fix: detect codex status with custom interrupt keys * test: remove unverified codex stale-status cases * fix(detect): match punctuation in codex interrupt hints Accept parentheses in remapped interrupt binding labels and simplify the cheap prefilter to a single any-gate shared by both manifest copies. --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- distribution/agent-detection/codex.toml | 16 +++++++++------- src/detect/manifests/codex.toml | 16 +++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/distribution/agent-detection/codex.toml b/distribution/agent-detection/codex.toml index 5021567a..8ceef314 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.15.1" min_engine_version = 3 -updated_at = "2026-09-14T00:00:00Z" +updated_at = "2026-09-15T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -84,11 +84,13 @@ 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'] +# 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 ]+\)$'] }] [[rules]] id = "osc_title_idle" diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index 5021567a..8ceef314 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.14.1" +version = "2026.09.15.1" min_engine_version = 3 -updated_at = "2026-09-14T00:00:00Z" +updated_at = "2026-09-15T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -84,11 +84,13 @@ 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'] +# 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 ]+\)$'] }] [[rules]] id = "osc_title_idle" From c00a62dda169beb472fc2f386d0f673ca100dfa4 Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Sun, 20 Sep 2026 17:10:16 +0300 Subject: [PATCH 28/99] fix: preserve delayed mouse reports with confirmed keyboard input (#4247) * fix: preserve delayed mouse reports with confirmed keyboard input refs #3480 * fix: capture geometry before replaying buffered mouse input refs #3480 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- src/client/input.rs | 85 +++++++--- src/client/loop_config.rs | 2 + src/client/mod.rs | 16 +- src/client/terminal_setup.rs | 296 ++++++++++++++++++++++++++++++++++- src/platform/mod.rs | 2 +- src/platform/unix_common.rs | 23 +++ src/raw_input.rs | 109 +++++++++++++ tests/client_mode.rs | 5 + 8 files changed, 505 insertions(+), 33 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index 41850f96..45656f84 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -42,6 +42,8 @@ pub fn stdin_reader_loop( host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, host_sgr_pixels_active: Arc<AtomicBool>, + host_escape_disambiguation_active: bool, + initial_host_input: Vec<u8>, #[cfg(unix)] direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>, #[cfg(unix)] direct_response_active: Arc<AtomicBool>, ) { @@ -53,6 +55,7 @@ pub fn stdin_reader_loop( host_mouse_capture_active, host_sgr_pixels_active, ); + let _ = (host_escape_disambiguation_active, initial_host_input); windows_stdin_reader_loop(event_tx, should_quit); } @@ -64,6 +67,8 @@ pub fn stdin_reader_loop( host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, + host_escape_disambiguation_active, + initial_host_input, direct_response, direct_response_active, ); @@ -77,6 +82,8 @@ fn unix_stdin_reader_loop( host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, host_sgr_pixels_active: Arc<AtomicBool>, + host_escape_disambiguation_active: bool, + initial_host_input: Vec<u8>, direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>, direct_response_active: Arc<AtomicBool>, ) { @@ -84,6 +91,7 @@ fn unix_stdin_reader_loop( let mut reader = stdin.lock(); let mut scratch = [0u8; 4096]; let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + framer.set_host_escape_disambiguation_active(host_escape_disambiguation_active); if host_color_query_sent { framer.host_color_query_sent(); framer.enable_host_color_scheme_change_tracking(); @@ -97,6 +105,57 @@ fn unix_stdin_reader_loop( let mut last_geometry = None; let mut direct_filter = super::direct_graphics::InputFilter::default(); + if !initial_host_input.is_empty() { + let sgr_pixels = host_sgr_pixels_active.load(Ordering::Acquire); + if sgr_pixels { + last_geometry = crate::input::mouse::HostGeometry::current(); + } + let chunks = framer.push(&initial_host_input); + if !send_unix_input_chunks( + chunks, + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) { + return; + } + if (framer.has_pending_input() || !pending_palette.is_empty()) + && stdin_read_ready( + &reader, + idle_flush_timeout_ms(&framer, host_mouse_capture_active.load(Ordering::Acquire)), + ) == Some(false) + { + let had_pending = framer.has_pending_input(); + let chunks = framer.flush_timeout(); + let held_escape = had_pending && chunks.is_empty(); + if !send_unix_input_chunks( + chunks, + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) || !flush_unix_palette_input(&event_tx, &mut pending_palette) + { + return; + } + if held_escape + && stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) + == Some(false) + && !send_unix_input_chunks( + framer.flush_timeout(), + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) + { + return; + } + } + pending_mode = framer.has_pending_input().then_some(sgr_pixels); + } + while !should_quit.load(Ordering::Acquire) { if direct_filter.has_pending() && stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) @@ -593,31 +652,7 @@ fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> { #[cfg(unix)] fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> { - #[repr(C)] - struct PollFd { - fd: i32, - events: i16, - revents: i16, - } - - unsafe extern "C" { - fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32; - } - - const POLLIN: i16 = 0x0001; - - let mut pfd = PollFd { - fd, - events: POLLIN, - revents: 0, - }; - - let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) }; - if result < 0 { - None - } else { - Some(result > 0) - } + crate::platform::poll_fd_readable(fd, timeout_ms).ok() } // --------------------------------------------------------------------------- diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs index 53ef9f38..97979bbf 100644 --- a/src/client/loop_config.rs +++ b/src/client/loop_config.rs @@ -9,6 +9,8 @@ pub(super) struct ClientLoopConfig { pub(super) pixel_geometry_enabled: bool, pub(super) pixel_geometry_fallback: bool, pub(super) mouse_capture_active: bool, + pub(super) host_escape_disambiguation_active: bool, + pub(super) initial_host_input: Vec<u8>, pub(super) endpoint_keybindings: bool, pub(super) remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, diff --git a/src/client/mod.rs b/src/client/mod.rs index 1cef87a7..a5094397 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -79,6 +79,7 @@ fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) { warn!(err = %err, "failed to re-assert host mouse capture"); } } + #[cfg(windows)] use terminal_setup::{is_ssh_session, windows_vti_input_backend_enabled}; #[cfg(test)] @@ -179,7 +180,7 @@ fn run_client_with_mode( let endpoint_keybindings = shell_config .as_ref() .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); - let loop_config = ClientLoopConfig { + let mut loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, @@ -188,6 +189,8 @@ fn run_client_with_mode( pixel_geometry_enabled, pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, + host_escape_disambiguation_active: false, + initial_host_input: Vec::new(), endpoint_keybindings, remote_image_paste_key, shell_config, @@ -278,7 +281,7 @@ fn run_client_with_mode( // The federated shell can show connection notices without any server snapshot. let direct_attach = attach_escape.is_some(); - let terminal_guard = if direct_attach { + let mut terminal_guard = if direct_attach { setup_direct_attach_terminal(mouse_capture) } else { setup_terminal(mouse_capture) @@ -287,6 +290,9 @@ fn run_client_with_mode( eprintln!("herdr: failed to set up terminal: {err}"); err })?; + loop_config.host_escape_disambiguation_active = + terminal_guard.host_escape_disambiguation_active(); + loop_config.initial_host_input = terminal_guard.take_buffered_host_input(); // Install a panic hook so the foreground client always restores its terminal. let panic_restore = terminal_guard.panic_restore(); @@ -374,7 +380,7 @@ async fn run_client_loop( initial_cell_height_px: u32, initial_pixel_geometry_exact: bool, should_quit: Arc<AtomicBool>, - config: ClientLoopConfig, + mut config: ClientLoopConfig, attach_escape: Option<AttachEscapeState>, _terminal_guard: &TerminalGuard, ) -> Result<(), ClientError> { @@ -475,6 +481,8 @@ async fn run_client_loop( let stdin_quit = should_quit.clone(); let stdin_mouse_capture_active = host_mouse_capture_active.clone(); let stdin_sgr_pixels_active = host_sgr_pixels_active.clone(); + let stdin_escape_disambiguation_active = config.host_escape_disambiguation_active; + let stdin_initial_host_input = std::mem::take(&mut config.initial_host_input); #[cfg(unix)] let stdin_direct_response = state.direct_graphics_response.clone(); #[cfg(unix)] @@ -490,6 +498,8 @@ async fn run_client_loop( will_query_host_cell_size, stdin_mouse_capture_active, stdin_sgr_pixels_active, + stdin_escape_disambiguation_active, + stdin_initial_host_input, #[cfg(unix)] stdin_direct_response, #[cfg(unix)] diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 3bb5c281..9c84985a 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -1,10 +1,14 @@ //! Terminal setup and restoration for the rendered client. use std::io::{self, Write as _}; +#[cfg(not(windows))] +use std::os::fd::AsRawFd as _; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; #[cfg(windows)] use std::sync::{Mutex, MutexGuard}; +#[cfg(not(windows))] +use std::time::{Duration, Instant}; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -44,6 +48,8 @@ pub(super) fn setup_terminal_with_capabilities( ) -> io::Result<TerminalGuard> { ratatui::init(); let mut terminal_guard = TerminalGuard { + host_escape_disambiguation_active: false, + buffered_host_input: Vec::new(), reset_keyboard_enhancements: false, reset_modify_other_keys: false, reset_host_color_scheme_reports: false, @@ -68,22 +74,25 @@ pub(super) fn setup_terminal_with_capabilities( WindowsVirtualTerminalInputSetup::default() }; - if enable_client_protocols { + let (host_escape_disambiguation_active, buffered_host_input) = if enable_client_protocols { + terminal_guard.reset_keyboard_enhancements = true; + push_keyboard_enhancement_flags()?; + let (active, buffered_input) = query_host_escape_disambiguation(); set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; if host_color_scheme_reports { terminal_guard.reset_host_color_scheme_reports = true; write_host_color_scheme_report_mode(&mut io::stdout(), true)?; } - terminal_guard.reset_keyboard_enhancements = true; - push_keyboard_enhancement_flags()?; + (active, buffered_input) } else { if should_query_host_terminal_theme() { write_host_color_scheme_report_mode(&mut io::stdout(), false)?; } set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste)?; - } + (false, Vec::new()) + }; #[cfg(windows)] if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { @@ -113,6 +122,8 @@ pub(super) fn setup_terminal_with_capabilities( execute!(io::stdout(), DisableLineWrap)?; + terminal_guard.host_escape_disambiguation_active = host_escape_disambiguation_active; + terminal_guard.buffered_host_input = buffered_host_input; Ok(terminal_guard) } @@ -122,6 +133,8 @@ pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: b /// Guard that restores the terminal when dropped. pub(super) struct TerminalGuard { + host_escape_disambiguation_active: bool, + buffered_host_input: Vec<u8>, reset_keyboard_enhancements: bool, reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool, @@ -131,6 +144,189 @@ pub(super) struct TerminalGuard { restore_windows_input_mode: Arc<WindowsInputModeRestore>, } +#[cfg(not(windows))] +const HOST_KEYBOARD_QUERY_TIMEOUT: Duration = Duration::from_millis(250); +#[cfg(not(windows))] +const MAX_BUFFERED_HOST_INPUT: usize = 64 * 1024; + +#[cfg(not(windows))] +#[derive(Default)] +struct HostKeyboardProbeResponses { + flags: Option<u16>, + primary_device_attributes: bool, +} + +#[cfg(not(windows))] +fn query_host_escape_disambiguation() -> (bool, Vec<u8>) { + const QUERY: &[u8] = b"\x1b[?u\x1b[c"; + + let mut buffered_input = Vec::new(); + if let Err(err) = io::stdout() + .write_all(QUERY) + .and_then(|()| io::stdout().flush()) + { + tracing::debug!(%err, "host keyboard enhancement query unavailable"); + return (false, buffered_input); + } + + // Bypass StdinLock's shared buffer so poll and read observe the same bytes. + let stdin = io::stdin(); + let stdin_fd = stdin.as_raw_fd(); + let deadline = Instant::now() + HOST_KEYBOARD_QUERY_TIMEOUT; + let mut responses = HostKeyboardProbeResponses::default(); + while !responses.primary_device_attributes && buffered_input.len() < MAX_BUFFERED_HOST_INPUT { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + let timeout_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + match crate::platform::poll_fd_readable(stdin_fd, timeout_ms) { + Ok(true) => {} + Ok(false) => break, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => { + tracing::debug!(%err, "host keyboard enhancement query read unavailable"); + break; + } + } + + let mut scratch = [0u8; 4096]; + let capacity = MAX_BUFFERED_HOST_INPUT - buffered_input.len(); + let read_limit = capacity.min(scratch.len()); + match crate::platform::read_fd(stdin_fd, &mut scratch[..read_limit]) { + Ok(0) => break, + Ok(read) => { + buffered_input.extend_from_slice(&scratch[..read]); + consume_host_keyboard_probe_responses(&mut buffered_input, &mut responses); + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => { + tracing::debug!(%err, "host keyboard enhancement query read failed"); + break; + } + } + } + + ( + host_escape_disambiguation_confirmed(&responses), + buffered_input, + ) +} + +#[cfg(not(windows))] +fn host_escape_disambiguation_confirmed(responses: &HostKeyboardProbeResponses) -> bool { + responses.primary_device_attributes + && responses + .flags + .is_some_and(|flags| flags & 0b0000_0001 != 0) +} + +#[cfg(not(windows))] +fn consume_host_keyboard_probe_responses( + buffered_input: &mut Vec<u8>, + responses: &mut HostKeyboardProbeResponses, +) { + const PASTE_START: &[u8] = b"\x1b[200~"; + const PASTE_END: &[u8] = b"\x1b[201~"; + + let mut offset = 0; + while offset < buffered_input.len() { + if buffered_input[offset..].starts_with(PASTE_START) { + let payload_start = offset + PASTE_START.len(); + let Some(relative_end) = buffered_input[payload_start..] + .windows(PASTE_END.len()) + .position(|bytes| bytes == PASTE_END) + else { + break; + }; + offset = payload_start + relative_end + PASTE_END.len(); + continue; + } + if let Some(control_string_end) = host_control_string_end(&buffered_input[offset..]) { + let Some(control_string_end) = control_string_end else { + break; + }; + offset += control_string_end; + continue; + } + if !buffered_input[offset..].starts_with(b"\x1b[?") { + offset += 1; + continue; + } + + let start = offset; + let mut end = start + 3; + while end < buffered_input.len() + && (buffered_input[end].is_ascii_digit() || buffered_input[end] == b';') + { + end += 1; + } + if end == buffered_input.len() { + break; + } + let body = &buffered_input[start + 3..end]; + let recognized = match buffered_input[end] { + b'u' if !body.is_empty() && body.iter().all(u8::is_ascii_digit) => { + std::str::from_utf8(body) + .ok() + .and_then(|flags| flags.parse().ok()) + .map(|flags| { + if !responses.primary_device_attributes { + responses.flags = Some(flags); + } + }) + .is_some() + } + b'c' if !body.is_empty() + && body + .iter() + .all(|byte| byte.is_ascii_digit() || *byte == b';') => + { + responses.primary_device_attributes = true; + true + } + _ => false, + }; + if recognized { + buffered_input.drain(start..=end); + } else { + offset += 1; + } + } +} + +#[cfg(not(windows))] +fn host_control_string_end(bytes: &[u8]) -> Option<Option<usize>> { + if bytes.first() != Some(&0x1b) { + return None; + } + + let allow_bel = if bytes.starts_with(b"\x1b]") { + true + } else if bytes + .get(1) + .is_some_and(|byte| matches!(*byte, b'P' | b'_' | b'^' | b'X')) + { + false + } else { + return None; + }; + + for offset in 2..bytes.len() { + if allow_bel && bytes[offset] == 0x07 { + return Some(Some(offset + 1)); + } + if bytes[offset..].starts_with(b"\x1b\\") { + return Some(Some(offset + 2)); + } + } + Some(None) +} + +#[cfg(windows)] +fn query_host_escape_disambiguation() -> (bool, Vec<u8>) { + (false, Vec::new()) +} + pub(super) fn write_host_color_scheme_report_mode( writer: &mut impl io::Write, enabled: bool, @@ -521,6 +717,14 @@ fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Res } impl TerminalGuard { + pub(super) fn host_escape_disambiguation_active(&self) -> bool { + self.host_escape_disambiguation_active + } + + pub(super) fn take_buffered_host_input(&mut self) -> Vec<u8> { + std::mem::take(&mut self.buffered_host_input) + } + #[cfg(windows)] pub(super) fn recover_windows_virtual_terminal_input(&self) -> io::Result<()> { let active = enable_windows_virtual_terminal_input( @@ -645,6 +849,90 @@ mod tests { } } + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_consumes_fragmented_responses_and_preserves_input() { + let stream = b"before\x1b[?7u-middle-\x1b[?1;2cafter"; + + for split in 1..stream.len() { + let mut buffered = Vec::new(); + let mut responses = HostKeyboardProbeResponses::default(); + buffered.extend_from_slice(&stream[..split]); + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + buffered.extend_from_slice(&stream[split..]); + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, Some(7), "split {split}"); + assert!(responses.primary_device_attributes, "split {split}"); + assert_eq!(buffered, b"before-middle-after", "split {split}"); + } + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_typed_input_before_responses() { + let mut buffered = b"aPtyped\x1b[?7u\x1b[?1;2c".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(buffered, b"aPtyped"); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_requires_disambiguation_bit_and_device_attributes() { + for (flags, expected) in [(0, false), (2, false), (7, true)] { + let mut buffered = format!("\x1b[?{flags}u\x1b[?1;2c").into_bytes(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(host_escape_disambiguation_confirmed(&responses), expected); + assert!(buffered.is_empty()); + } + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_requires_flags_before_device_attributes() { + let mut buffered = b"\x1b[?1;2c\x1b[?7uinput".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, None); + assert!(responses.primary_device_attributes); + assert_eq!(buffered, b"input"); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_response_shaped_payloads() { + let opaque = b"\x1b[200~paste \x1b[?1u \x1b[?1;2c\x1b[201~-\x1bPdata \x1b[?7u\x1b\\"; + let mut buffered = [opaque.as_slice(), b"\x1b[?7u\x1b[?1;2c"].concat(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(buffered, opaque); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_malformed_responses() { + let mut buffered = b"a\x1b[?7;1ub\x1b[?65536uc".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, None); + assert!(!responses.primary_device_attributes); + assert_eq!(buffered, b"a\x1b[?7;1ub\x1b[?65536uc"); + } + #[test] fn windows_native_mouse_capture_reasserts_final_encoding_after_native_enable() { let mut output = SharedOutput::default(); diff --git a/src/platform/mod.rs b/src/platform/mod.rs index eb84e0b1..2ba3e6f3 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -48,7 +48,7 @@ impl ChildExitReason { } #[cfg(unix)] -pub(crate) use unix_common::classify_child_exit; +pub(crate) use unix_common::{classify_child_exit, poll_fd_readable, read_fd}; #[cfg(not(any(unix, windows)))] pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason { diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index a937b863..6063edd9 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -8,6 +8,29 @@ pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::C } } +pub(crate) fn read_fd(fd: std::os::fd::RawFd, data: &mut [u8]) -> std::io::Result<usize> { + let result = unsafe { libc::read(fd, data.as_mut_ptr().cast(), data.len()) }; + if result < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(result as usize) + } +} + +pub(crate) fn poll_fd_readable(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result<bool> { + let mut descriptor = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if result < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(result > 0) + } +} + pub(crate) fn shutdown_client_stream(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { let crate::ipc::LocalStream::UdSocket(stream) = stream; stream.inner().shutdown(std::net::Shutdown::Both) diff --git a/src/raw_input.rs b/src/raw_input.rs index 147d6e39..cfbcba62 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -150,6 +150,7 @@ pub(crate) struct RawInputByteFramer { host_color_scheme_change_tracking: bool, host_appearance_query_on_focus: bool, split_coalesced_escape: bool, + host_escape_disambiguation_active: bool, } const HOST_COLOR_QUERY_REPLIES: u16 = 258; @@ -218,6 +219,11 @@ impl RawInputByteFramer { !self.buffer.is_empty() } + #[cfg(any(unix, test))] + pub(crate) fn set_host_escape_disambiguation_active(&mut self, active: bool) { + self.host_escape_disambiguation_active = active; + } + #[cfg(unix)] pub(crate) fn has_pending_lone_escape(&self) -> bool { self.buffer.as_slice() == [ESC] @@ -267,6 +273,16 @@ impl RawInputByteFramer { return chunks; } + if self.host_escape_disambiguation_active + && starts_with_bounded_incomplete_escape_sequence(&self.buffer) + { + tracing::trace!( + len = self.buffer.len(), + "holding incomplete host escape sequence with disambiguation active" + ); + return chunks; + } + if self.lone_escape_recently_flushed && self.buffer.starts_with(b"[<") { tracing::debug!( len = self.buffer.len(), @@ -472,6 +488,15 @@ impl RawInputByteFramer { continue; } + if self.host_escape_disambiguation_active + && self.buffer.first() == Some(&ESC) + && self.buffer.len() > 1 + && !starts_with_known_escape_introducer(&self.buffer) + { + self.buffer.drain(..1); + continue; + } + let Some((event, consumed)) = extract_one_event(&self.buffer) else { break; }; @@ -831,6 +856,25 @@ fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool { .all(|byte| byte.is_ascii_digit() || *byte == b';') } +fn starts_with_known_escape_introducer(buffer: &[u8]) -> bool { + buffer + .get(1) + .is_some_and(|byte| matches!(*byte, b'[' | b'O' | b']' | b'P' | b'_' | b'^' | b'X' | ESC)) +} + +fn starts_with_bounded_incomplete_escape_sequence(buffer: &[u8]) -> bool { + if buffer.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES { + return false; + } + if buffer == [ESC] || buffer == b"\x1bO" { + return true; + } + let Some(body) = buffer.strip_prefix(b"\x1b[") else { + return false; + }; + body.iter().all(|byte| matches!(*byte, 0x20..=0x3f)) +} + #[cfg(any(unix, windows, test))] fn starts_with_incomplete_default_mouse_sequence(buffer: &[u8]) -> bool { buffer.starts_with(b"\x1b[M") && buffer.len() < 6 @@ -2016,6 +2060,71 @@ mod tests { assert!(framer.timed_out_mouse_prefix.is_none()); } + #[test] + fn confirmed_host_disambiguation_retains_split_sgr_mouse_without_escape() { + for (prefix, tail) in [ + (b"\x1b".as_slice(), b"[<0;5;5M".as_slice()), + (b"\x1b[".as_slice(), b"<0;5;5M".as_slice()), + (b"\x1b[<0;".as_slice(), b"5;5M".as_slice()), + ] { + let mut framer = RawInputFramer::default(); + framer + .byte_framer + .set_host_escape_disambiguation_active(true); + + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(tail); + + assert!(matches!( + events.as_slice(), + [RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 4, + row: 4, + .. + })] + )); + } + } + + #[test] + fn confirmed_host_disambiguation_drops_stale_escape_before_plain_input() { + let mut framer = RawInputFramer::default(); + framer + .byte_framer + .set_host_escape_disambiguation_active(true); + + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(b"x"); + + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); + } + + #[test] + fn confirmed_host_disambiguation_keeps_kitty_escape_immediate() { + let mut framer = RawInputFramer::default(); + framer + .byte_framer + .set_host_escape_disambiguation_active(true); + + let events = framer.push(b"\x1b[27u"); + + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Esc, + KeyModifiers::empty(), + ); + } + #[test] fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { let mut framer = RawInputFramer::default(); diff --git a/tests/client_mode.rs b/tests/client_mode.rs index b412d106..0b94b32e 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -344,6 +344,11 @@ fn direct_attach_initial_mouse_capture_follows_config() { "direct attach must enable host bracketed paste; output: {:?}", read_output(&output) ); + assert!( + !read_output(&output).contains("\x1b[?u"), + "direct attach must not query rendered-client keyboard state; output: {:?}", + read_output(&output) + ); let restore_watermark = output_len(&output); attach From 74505861e40c48e070e711bd4de662a17b0939b3 Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Sun, 20 Sep 2026 22:33:50 +0800 Subject: [PATCH 29/99] fix: keep workspace navigation visible in terminal theme (#4408) * fix: keep workspace navigation visible in terminal theme refs #4300 * fix: clear workspace highlight on directional pane focus refs #4300 --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- src/client/mod.rs | 1 + src/client/shell.rs | 2 +- src/client/shell/actions.rs | 21 + src/client/shell/composition.rs | 18 +- src/client/shell/endpoint_navigation.rs | 2 + src/client/shell/endpoint_sidebar.rs | 15 +- src/client/shell/endpoints.rs | 6 + src/client/shell/input.rs | 1 + src/client/shell/mobile.rs | 9 +- src/client/shell/sidebar.rs | 50 +- src/client/shell/state.rs | 4 + .../shell/tests/workspace_navigation.rs | 467 +++++++++++++++++- src/client/shell/workspace_navigation.rs | 54 +- 13 files changed, 620 insertions(+), 30 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index a5094397..b9335e06 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -2124,6 +2124,7 @@ async fn run_client_loop( let (effects, notification_repaint) = shell.tick_notifications(now); outcome.repaint |= notification_repaint | shell.tick_copy_feedback(now) + | shell.tick_workspace_highlight(now) | shell.tick_endpoint_error(now); let frame = outcome .repaint diff --git a/src/client/shell.rs b/src/client/shell.rs index 95ee5e78..27d30df9 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -4,7 +4,7 @@ mod actions; mod agent_sidebar; mod aggregate_navigation; mod workspace_navigation; -use workspace_navigation::WorkspaceNavigationTarget; +use workspace_navigation::{PendingWorkspaceHighlight, WorkspaceNavigationTarget}; mod composition; mod config; mod context_menu; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index e3885626..da4edd49 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -141,6 +141,7 @@ impl ClientShellState { return; } if action == crate::input::KeybindAction::WorkspacePicker { + self.pending_workspace_highlight = None; self.mobile_switcher_scroll = 0; self.reveal_mobile_workspace = false; self.mode = ClientShellMode::Navigate; @@ -349,6 +350,19 @@ impl ClientShellState { kind: PendingEndpointKind, outcome: &mut ClientShellInput, ) -> bool { + let changes_focus = match &method { + crate::api::schema::Method::WorkspaceFocus(_) + | crate::api::schema::Method::TabFocus(_) + | crate::api::schema::Method::PaneFocus(_) + | crate::api::schema::Method::PaneFocusDirection(_) => true, + crate::api::schema::Method::WorkspaceCreate(params) => params.focus, + crate::api::schema::Method::TabCreate(params) => params.focus, + crate::api::schema::Method::PaneSplit(params) => params.focus, + _ => false, + }; + if changes_focus { + outcome.repaint |= self.pending_workspace_highlight.take().is_some(); + } if !self.endpoint_is_online(&self.active_endpoint_id) { let label = self.active_endpoint_label().to_owned(); outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready")); @@ -495,6 +509,13 @@ impl ClientShellState { self.endpoint_notice_seen.remove(&timeout_key); } if let Err(error) = &result { + if self + .pending_workspace_highlight + .as_ref() + .is_some_and(|pending| pending.request_id == request_id) + { + self.pending_workspace_highlight = None; + } let code = error.code.as_deref().unwrap_or("invalid_response"); if !matches!( code, diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 10dc6633..4da21e77 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -40,6 +40,12 @@ impl ClientShellState { .navigate_workspace_id .as_ref() .is_some_and(|target| self.navigation_target_valid(target)); + let pending_workspace_highlight = + self.pending_workspace_highlight.as_ref().filter(|pending| { + self.mode != ClientShellMode::Navigate + && pending.target.endpoint_id == self.active_endpoint_id + && self.navigation_target_valid(&pending.target) + }); // A resize invalidates pane geometry, not the healthy Local workspace chrome. let local_snapshot = self.snapshot.as_deref().filter(|_| { self.endpoints.len() == 1 @@ -65,7 +71,8 @@ impl ClientShellState { selected_workspace_id: self .navigate_workspace_id .as_ref() - .filter(|_| valid_navigation_target), + .filter(|_| valid_navigation_target) + .or_else(|| pending_workspace_highlight.map(|pending| &pending.target)), reveal_navigation_workspace: &mut self.reveal_navigation_workspace, dragged_workspace_id: None, workspace_drop_indicator_row: None, @@ -143,6 +150,12 @@ impl ClientShellState { .navigate_workspace_id .as_ref() .is_some_and(|target| self.navigation_target_valid(target)); + let pending_workspace_highlight = + self.pending_workspace_highlight.as_ref().filter(|pending| { + self.mode != ClientShellMode::Navigate + && pending.target.endpoint_id == self.active_endpoint_id + && self.navigation_target_valid(&pending.target) + }); if self.snapshot.is_none() || self.pane_surface.is_none() { return Some(self.compose_unavailable(cols, rows)); } @@ -200,7 +213,8 @@ impl ClientShellState { selected_workspace_id: self .navigate_workspace_id .as_ref() - .filter(|_| valid_navigation_target), + .filter(|_| valid_navigation_target) + .or_else(|| pending_workspace_highlight.map(|pending| &pending.target)), reveal_navigation_workspace: &mut self.reveal_navigation_workspace, dragged_workspace_id, workspace_drop_indicator_row, diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index c15fd434..a1f7ac2c 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -230,6 +230,7 @@ impl ClientShellState { endpoint_id: ClientEndpointId, outcome: &mut ClientShellInput, ) -> bool { + self.pending_workspace_highlight = None; self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); if !online && !endpoint_id.is_local() { @@ -255,6 +256,7 @@ impl ClientShellState { target: ClientEndpointFocusTarget, outcome: &mut ClientShellInput, ) -> bool { + self.pending_workspace_highlight = None; self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); if !online && !endpoint_id.is_local() { diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs index 34d2ccdc..c4f701c3 100644 --- a/src/client/shell/endpoint_sidebar.rs +++ b/src/client/shell/endpoint_sidebar.rs @@ -128,7 +128,13 @@ pub(super) fn render_collapsed( if selected { buffer.set_style(rect, Style::default().bg(selection_background)); } else if focused { - buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + buffer.set_style( + rect, + Style::default().bg(super::sidebar::workspace_active_background( + palette, + state.selected_workspace_id.is_some(), + )), + ); } let stale = endpoint.status != ClientEndpointStatus::Online; let number = format!(" {}", workspace.number); @@ -449,19 +455,16 @@ pub(super) fn render_expanded( super::sidebar::render_workspace_rows( buffer, nested, - workspace, status, config.status_indicators, entry, tokens, - endpoint_active, + endpoint_active && workspace.focused, selected, + state.selected_workspace_id.is_some(), false, palette, ); - if selected && palette.selection_bg == ratatui::style::Color::Reset { - buffer.set_style(nested, Style::default().bg(palette.active_row_bg)); - } if endpoint.status != ClientEndpointStatus::Online { buffer.set_style( rect, diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 3d1d419b..513842ba 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -108,6 +108,9 @@ impl ClientShellState { } pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) { + if endpoint_id == &self.active_endpoint_id { + self.pending_workspace_highlight = None; + } self.retire_endpoint_notifications(endpoint_id); if let Some(endpoint) = self .endpoints @@ -131,6 +134,9 @@ impl ClientShellState { endpoint_id: &ClientEndpointId, status: ClientEndpointStatus, ) { + if endpoint_id == &self.active_endpoint_id && status != ClientEndpointStatus::Online { + self.pending_workspace_highlight = None; + } if let Some(endpoint) = self .endpoints .iter_mut() diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index c6d458c8..f1e7ea25 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -645,6 +645,7 @@ impl ClientShellState { ) { use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; + self.pending_workspace_highlight = None; if key.code == KeyCode::Esc || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 8f06f54b..f4ae0af0 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -768,7 +768,13 @@ fn mobile_items( palette.surface0 } } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused { - palette.surface_dim + if selected_workspace_id.is_some() + && palette.surface0 == ratatui::style::Color::Reset + { + palette.panel_bg + } else { + palette.surface_dim + } } else { palette.panel_bg }; @@ -957,6 +963,7 @@ impl ClientShellState { { self.mobile_switcher_scroll = 0; self.reveal_mobile_workspace = false; + self.pending_workspace_highlight = None; self.mode = ClientShellMode::Navigate; self.navigate_workspace_id = self.focused_navigation_target(); outcome.repaint = true; diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 161aecd5..f94f6bdc 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -4,6 +4,26 @@ use ratatui::{ widgets::{Paragraph, Widget}, }; +fn workspace_selection_background(palette: &Palette) -> ratatui::style::Color { + if palette.selection_bg == ratatui::style::Color::Reset { + palette.active_row_bg + } else { + palette.selection_bg + } +} + +pub(in crate::client::shell) fn workspace_active_background( + palette: &Palette, + navigating: bool, +) -> ratatui::style::Color { + // The fallback cursor shares the active-row color; only fill the cursor while navigating. + if navigating && palette.selection_bg == ratatui::style::Color::Reset { + palette.sidebar_bg + } else { + palette.active_row_bg + } +} + pub(in crate::client::shell) fn collapsed_sidebar_sections( area: Rect, ) -> (Rect, Option<u16>, Rect) { @@ -33,6 +53,8 @@ pub(crate) fn render_collapsed_sidebar( hits: &mut ShellHitMap, ) { let palette = &config.palette; + let selection_background = workspace_selection_background(palette); + let active_background = workspace_active_background(palette, selected_workspace_id.is_some()); render_sidebar_background(buffer, area, palette); let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area); for (index, workspace) in snapshot @@ -48,23 +70,17 @@ pub(crate) fn render_collapsed_sidebar( 1, ); let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); - let selection_background = - if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset { - palette.active_row_bg - } else { - palette.selection_bg - }; if selected { buffer.set_style(rect, Style::default().bg(selection_background)); } else if workspace.focused { - buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); + buffer.set_style(rect, Style::default().bg(active_background)); } let number_style = if selected { Style::default() .fg(palette.overlay1) .bg(selection_background) } else if workspace.focused { - Style::default().fg(palette.text).bg(palette.active_row_bg) + Style::default().fg(palette.text).bg(active_background) } else { Style::default().fg(palette.overlay0) }; @@ -308,13 +324,13 @@ pub(crate) fn render_sidebar( render_workspace_rows( buffer, rect, - workspace, status, config.status_indicators, entry, rows, - true, + workspace.focused, selected, + state.selected_workspace_id.is_some(), dragged, palette, ); @@ -639,13 +655,13 @@ pub(in crate::client::shell) fn workspace_rows( pub(in crate::client::shell) fn render_workspace_rows( buffer: &mut Buffer, area: Rect, - workspace: &ClientShellWorkspace, status: crate::api::schema::AgentStatus, indicators: crate::config::StatusIndicatorStyle, entry: &WorkspaceEntry, rows: Vec<Vec<crate::ui::ResolvedToken>>, - endpoint_active: bool, + focused: bool, selected: bool, + navigating: bool, dragged: bool, palette: &Palette, ) { @@ -680,7 +696,7 @@ pub(in crate::client::shell) fn render_workspace_rows( } else { x = x.saturating_add(3); } - let highlighted = endpoint_active && workspace.focused || dragged; + let highlighted = focused || dragged; let workspace_style = Style::default() .fg(if highlighted { palette.text @@ -692,7 +708,7 @@ pub(in crate::client::shell) fn render_workspace_rows( } else { Modifier::empty() }); - let secondary_style = Style::default().fg(if endpoint_active && workspace.focused { + let secondary_style = Style::default().fg(if focused { palette.mauve } else { palette.overlay0 @@ -717,11 +733,11 @@ pub(in crate::client::shell) fn render_workspace_rows( } let background = if selected { - Some(palette.selection_bg) + Some(workspace_selection_background(palette)) } else if dragged { Some(palette.surface1) - } else if endpoint_active && workspace.focused { - Some(palette.active_row_bg) + } else if focused { + Some(workspace_active_background(palette, navigating)) } else { None }; diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 7128db57..55a065d5 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -880,6 +880,7 @@ pub(crate) struct ClientShellState { pub(super) collapsed_endpoints: HashSet<ClientEndpointId>, pub(super) mode: ClientShellMode, pub(super) navigate_workspace_id: Option<WorkspaceNavigationTarget>, + pub(super) pending_workspace_highlight: Option<PendingWorkspaceHighlight>, pub(super) reveal_navigation_workspace: bool, pub(super) overlay: Option<ClientShellOverlay>, pub(super) previous_pane_id: Option<String>, @@ -1043,6 +1044,7 @@ impl ClientShellState { collapsed_endpoints: HashSet::new(), mode: ClientShellMode::Terminal, navigate_workspace_id: None, + pending_workspace_highlight: None, reveal_navigation_workspace: false, overlay, previous_pane_id: None, @@ -1233,6 +1235,7 @@ impl ClientShellState { self.endpoint_error = None; self.endpoint_error_deadline = None; self.navigate_workspace_id = None; + self.pending_workspace_highlight = None; self.overlay = self .config .startup_onboarding @@ -1549,6 +1552,7 @@ impl ClientShellState { } } self.snapshot = Some(snapshot); + self.reconcile_pending_workspace_highlight(); let pending_surface = self.pending_pane_surface.take(); if let Some(surface) = pending_surface { let matching = self.snapshot.as_ref().is_some_and(|snapshot| { diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs index 7ea02066..c117a319 100644 --- a/src/client/shell/tests/workspace_navigation.rs +++ b/src/client/shell/tests/workspace_navigation.rs @@ -71,6 +71,81 @@ fn workspace_rect(state: &ClientShellState, endpoint: &ClientEndpointId, workspa .expect("visible workspace") } +#[test] +fn local_navigation_highlight_stays_visible_with_terminal_theme() { + use ratatui::style::Color; + + for compact in [false, true] { + for selection_bg in [Color::Reset, Color::Rgb(70, 63, 93)] { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.palette = Palette::terminal(); + config.palette.selection_bg = selection_bg; + let expected_bg = if selection_bg == Color::Reset { + config.palette.active_row_bg + } else { + selection_bg + }; + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(workspaces(3))); + state.set_pane_surface(surface()); + state.sidebar_collapsed = compact; + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + + for workspace_id in ["ws_1", "ws_2"] { + assert_selected(&state, &ClientEndpointId::Local, workspace_id); + let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap(); + let selected = workspace_rect(&state, &ClientEndpointId::Local, workspace_id); + for y in selected.y..selected.bottom() { + for x in selected.x..selected.right() { + assert_eq!( + buffer[(x, y)].bg, + expected_bg, + "compact={compact}, {workspace_id}, ({x}, {y})" + ); + } + } + let untouched = workspace_rect(&state, &ClientEndpointId::Local, "ws_3"); + assert_ne!(buffer[(untouched.x, untouched.y)].bg, expected_bg); + if workspace_id != "ws_1" { + let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1"); + assert_eq!( + buffer[(focused.x, focused.y)].bg, + if selection_bg == Color::Reset { + state.config.palette.sidebar_bg + } else { + state.config.palette.active_row_bg + } + ); + assert_ne!(buffer[(focused.x, focused.y)].bg, expected_bg); + } + preview_key(&mut state, b"\x1b[B"); + } + assert_eq!( + state + .snapshot + .as_ref() + .unwrap() + .focused_workspace_id + .as_deref(), + Some("ws_1") + ); + preview_key(&mut state, b"\x1b"); + let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap(); + let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1"); + assert_eq!( + buffer[(focused.x, focused.y)].bg, + state.config.palette.active_row_bg + ); + let cancelled = workspace_rect(&state, &ClientEndpointId::Local, "ws_3"); + assert_eq!( + buffer[(cancelled.x, cancelled.y)].bg, + state.config.palette.sidebar_bg + ); + } + } +} + #[test] fn navigation_highlights_only_the_preview_and_activates_on_enter() { for (compact, cols) in [(true, 100), (false, 100), (false, 44)] { @@ -113,7 +188,13 @@ fn navigation_highlights_only_the_preview_and_activates_on_enter() { assert_ne!(buffer[(other.x + 2, other.y)].bg, color); assert_eq!( buffer[(focused.x + 2, focused.y)].bg, - if cols == 44 { + if terminal_theme { + if cols == 44 { + palette.panel_bg + } else { + palette.sidebar_bg + } + } else if cols == 44 { palette.surface_dim } else { palette.active_row_bg @@ -503,3 +584,387 @@ fn aggregate_navigation_reveals_overflow_and_preserves_order() { assert_selected(&state, &ClientEndpointId::Local, "ws_1"); } } + +fn local_navigation_state(compact: bool) -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.palette = Palette::terminal(); + state.sidebar_collapsed = compact; + state.set_snapshot(Box::new(workspaces(3))); + state.set_pane_surface(surface()); + state.compose(100, 28).unwrap(); + state +} + +fn request_local_navigation(state: &mut ClientShellState, down: usize) -> String { + enter_navigation(state); + for _ in 0..down { + preview_key(state, b"\x1b[B"); + } + let outcome = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else { + panic!("expected a local workspace focus request"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::WorkspaceFocus(_) + )); + request.id.clone() +} + +fn assert_local_highlight(state: &mut ClientShellState, selected_id: &str) { + let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap(); + for workspace_id in ["ws_1", "ws_2", "ws_3"] { + let rect = workspace_rect(state, &ClientEndpointId::Local, workspace_id); + assert_eq!( + (rect.x..rect.right()) + .any(|x| buffer[(x, rect.y)].bg == state.config.palette.active_row_bg), + workspace_id == selected_id, + "expected only {selected_id} highlighted, checking {workspace_id}" + ); + } +} + +fn set_local_focus(state: &mut ClientShellState, workspace_id: &str, revision: u64) { + let mut snapshot = workspaces(3); + snapshot.revision = revision; + snapshot.focused_workspace_id = Some(workspace_id.into()); + for workspace in &mut snapshot.workspaces { + workspace.focused = workspace.workspace_id == workspace_id; + } + state.set_snapshot(Box::new(snapshot)); + let mut frame = surface(); + frame.projection_revision = revision; + state.set_pane_surface(frame); +} + +#[test] +fn accepted_local_navigation_keeps_highlight_until_authoritative_focus() { + for compact in [false, true] { + for response_first in [false, true] { + let mut state = local_navigation_state(compact); + let request_id = request_local_navigation(&mut state, 2); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); + assert_eq!( + state + .snapshot + .as_ref() + .unwrap() + .focused_workspace_id + .as_deref(), + Some("ws_1") + ); + assert_eq!(state.focused_pane_id().as_deref(), Some("pane_1")); + assert_local_highlight(&mut state, "ws_3"); + state.invalidate_pane_surface(); + assert_local_highlight(&mut state, "ws_3"); + state.set_pane_surface(surface()); + if response_first { + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert_local_highlight(&mut state, "ws_3"); + } + set_local_focus(&mut state, "ws_1", 2); + assert_local_highlight(&mut state, "ws_3"); + set_local_focus(&mut state, "ws_3", 3); + assert_local_highlight(&mut state, "ws_3"); + if !response_first { + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + } + set_local_focus(&mut state, "ws_2", 4); + assert_local_highlight(&mut state, "ws_2"); + } + } +} + +#[test] +fn failed_local_navigation_releases_only_its_own_highlight() { + for failure in ["rejected", "endpoint_timeout", "cancelled"] { + let mut state = local_navigation_state(false); + let request_id = request_local_navigation(&mut state, 2); + assert_local_highlight(&mut state, "ws_3"); + if failure == "cancelled" { + assert!(state.cancel_endpoint_request(&request_id)); + } else { + state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some(failure.into()), + message: "focus failed".into(), + }), + ); + } + assert_local_highlight(&mut state, "ws_1"); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert_local_highlight(&mut state, "ws_1"); + } + for old_down in [1, 2] { + let mut state = local_navigation_state(false); + let old_request = request_local_navigation(&mut state, old_down); + let latest_request = request_local_navigation(&mut state, 2); + state.cancel_endpoint_request(&old_request); + assert_local_highlight(&mut state, "ws_3"); + state.cancel_endpoint_request(&latest_request); + assert_local_highlight(&mut state, "ws_1"); + } +} + +#[test] +fn pending_navigation_highlight_does_not_survive_identity_changes() { + for change in [ + "disconnect", + "retire", + "boot", + "generation", + "deleted", + "endpoint", + ] { + let mut state = local_navigation_state(false); + let request_id = request_local_navigation(&mut state, 2); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + assert_local_highlight(&mut state, "ws_3"); + let mut snapshot = workspaces(3); + match change { + "disconnect" => { + state.mark_endpoint_disconnected(&ClientEndpointId::Local); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot)); + } + "retire" => { + state.retire_endpoint(&ClientEndpointId::Local); + state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online); + state.set_snapshot(Box::new(snapshot)); + } + "boot" => { + snapshot.boot_id = "replacement-boot".into(); + state.set_snapshot(Box::new(snapshot)); + } + "generation" => state.set_endpoint_snapshot_for_generation( + &ClientEndpointId::Local, + 1, + Box::new(snapshot), + ), + "deleted" => { + snapshot.workspaces.pop(); + state.set_snapshot(Box::new(snapshot)); + state.set_snapshot(Box::new(workspaces(3))); + } + "endpoint" => { + let profile = remote_profile(); + let remote = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&remote, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&remote, Box::new(snapshot)); + assert!(state.activate_endpoint_projection(&remote)); + assert!(state.pending_workspace_highlight.is_none()); + assert!(state.activate_endpoint_projection(&ClientEndpointId::Local)); + state.set_endpoint_catalog(&[]); + } + _ => unreachable!(), + } + assert!(state.pending_workspace_highlight.is_none(), "{change}"); + let mut frame = surface(); + frame.boot_id = state.snapshot.as_ref().unwrap().boot_id.clone(); + state.set_pane_surface(frame); + assert_local_highlight(&mut state, "ws_1"); + } +} + +#[test] +fn navigation_highlight_requires_enqueued_focus_and_yields_to_new_intent() { + let mut state = local_navigation_state(false); + state.set_endpoint_methods(Some(Vec::new())); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + let refused = state.handle_input_bytes(b"\r"); + assert!(refused.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.pending_workspace_highlight.is_none()); + // The unsupported-action notice spans the sidebar on this narrow frame. + assert!(state.visible_endpoint_notice.take().is_some()); + assert_local_highlight(&mut state, "ws_1"); + + state.set_endpoint_methods(None); + request_local_navigation(&mut state, 2); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b"); + assert_local_highlight(&mut state, "ws_1"); + + request_local_navigation(&mut state, 2); + let mut unrelated = ClientShellInput::default(); + state.push_endpoint_method( + crate::api::schema::Method::ServerReloadConfig(crate::api::schema::EmptyParams::default()), + &mut unrelated, + ); + let [ClientShellAction::Endpoint { request, .. }] = unrelated.actions.as_slice() else { + panic!("expected unrelated request"); + }; + state.cancel_endpoint_request(&request.id); + assert_local_highlight(&mut state, "ws_3"); + let mut focus = ClientShellInput::default(); + state.focus_or_activate( + ClientEndpointId::Local, + ClientEndpointFocusTarget::Workspace("ws_2".into()), + &mut focus, + ); + assert!(state.pending_workspace_highlight.is_none()); +} + +#[test] +fn directional_pane_focus_releases_an_accepted_workspace_highlight() { + use crate::api::schema::{Method, PaneDirection, ResponseResult}; + + for (key, direction) in [ + (b'h', PaneDirection::Left), + (b'j', PaneDirection::Down), + (b'k', PaneDirection::Up), + (b'l', PaneDirection::Right), + ] { + for rejected in [false, true] { + let mut state = local_navigation_state(false); + let pending_request = request_local_navigation(&mut state, 2); + assert_local_highlight(&mut state, "ws_3"); + preview_key(&mut state, &[0x02]); + let outcome = state.handle_input_bytes(&[key]); + let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else { + panic!("expected a directional pane focus request"); + }; + let Method::PaneFocusDirection(params) = &request.method else { + panic!("expected PaneFocusDirection"); + }; + assert_eq!(params.direction, direction); + assert_eq!(params.pane_id.as_deref(), Some("pane_1")); + assert!(state.pending_workspace_highlight.is_none()); + assert_local_highlight(&mut state, "ws_1"); + let result = if rejected { + Err(ClientShellEndpointError { + code: Some("rejected".into()), + message: "focus rejected".into(), + }) + } else { + Ok(ResponseResult::Ok {}) + }; + state.handle_endpoint_result("boot-1", &pending_request, result); + assert_local_highlight(&mut state, "ws_1"); + } + } +} + +#[test] +fn direct_agent_focus_repaints_when_releasing_a_workspace_highlight() { + let mut config = Config::default(); + config.keys.focus_agent = crate::config::BindingConfig::one("ctrl+alt+1"); + let mut projected = workspaces(3); + projected.agents.push(agent("agent", AgentStatus::Idle, 1)); + + for pending in [false, true] { + let mut state = local_navigation_state(false); + state.config.keybinds = ClientShellConfig::from_config(&config).keybinds; + state.set_snapshot(Box::new(projected.clone())); + state.compose(100, 28).unwrap(); + if pending { + request_local_navigation(&mut state, 2); + assert_local_highlight(&mut state, "ws_3"); + } + + // Direct bindings do not inherit the repaint from leaving prefix mode. + let outcome = + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('1'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ))]); + assert!( + matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_1")) + ); + assert!(state.pending_workspace_highlight.is_none()); + assert_eq!(outcome.repaint, pending); + assert_local_highlight(&mut state, "ws_1"); + } +} + +#[test] +fn cancelled_close_does_not_restore_an_older_navigation_highlight() { + let mut state = local_navigation_state(false); + request_local_navigation(&mut state, 2); + state.open_confirm_close_overlay("ws_1".into()); + preview_key(&mut state, b"\x1b"); + assert_eq!(state.mode, ClientShellMode::Navigate); + preview_key(&mut state, b"\x1b"); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert_local_highlight(&mut state, "ws_1"); +} + +#[test] +fn coalesced_navigation_focus_does_not_leave_a_permanent_highlight() { + let mut state = local_navigation_state(false); + let before_request = std::time::Instant::now(); + let request_id = request_local_navigation(&mut state, 2); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(crate::api::schema::ResponseResult::Ok {}), + ); + // Another client can focus the original workspace before the server projects + // either change, so a successful request need not produce a new snapshot. + assert!(!state.tick_workspace_highlight(before_request)); + assert_local_highlight(&mut state, "ws_3"); + let now = std::time::Instant::now(); + assert!(state.tick_workspace_highlight(now + std::time::Duration::from_secs(2))); + assert_local_highlight(&mut state, "ws_1"); + assert!(!state.tick_workspace_highlight(now + std::time::Duration::from_secs(3))); +} + +#[test] +fn navigation_highlight_ends_for_noop_focus_and_focused_creation() { + let mut state = local_navigation_state(false); + request_local_navigation(&mut state, 0); + assert!(state.pending_workspace_highlight.is_none()); + set_local_focus(&mut state, "ws_2", 2); + assert_local_highlight(&mut state, "ws_2"); + + for focus in [false, true] { + for method in [ + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + source_workspace_id: None, + cwd: None, + focus, + label: None, + env: Default::default(), + }, + ), + crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: Some("ws_1".into()), + cwd: None, + focus, + label: None, + env: Default::default(), + }), + ] { + let mut state = local_navigation_state(false); + request_local_navigation(&mut state, 2); + let mut outcome = ClientShellInput::default(); + state.push_endpoint_method(method, &mut outcome); + assert_eq!(state.pending_workspace_highlight.is_none(), focus); + assert_local_highlight(&mut state, if focus { "ws_1" } else { "ws_3" }); + } + } +} diff --git a/src/client/shell/workspace_navigation.rs b/src/client/shell/workspace_navigation.rs index 6139fe0d..19fa1a39 100644 --- a/src/client/shell/workspace_navigation.rs +++ b/src/client/shell/workspace_navigation.rs @@ -9,6 +9,13 @@ pub(super) struct WorkspaceNavigationTarget { generation: Option<u64>, } +/// Display-only continuity while a direct focus request awaits its authoritative snapshot. +pub(super) struct PendingWorkspaceHighlight { + pub(super) target: WorkspaceNavigationTarget, + pub(super) request_id: String, + expires_at: std::time::Instant, +} + impl WorkspaceNavigationTarget { pub(super) fn matches(&self, endpoint_id: &ClientEndpointId, workspace_id: &str) -> bool { &self.endpoint_id == endpoint_id && self.workspace_id == workspace_id @@ -16,6 +23,35 @@ impl WorkspaceNavigationTarget { } impl ClientShellState { + pub(crate) fn tick_workspace_highlight(&mut self, now: std::time::Instant) -> bool { + if self + .pending_workspace_highlight + .as_ref() + .is_some_and(|pending| now >= pending.expires_at) + { + self.pending_workspace_highlight = None; + return true; + } + false + } + + pub(super) fn reconcile_pending_workspace_highlight(&mut self) { + if self + .pending_workspace_highlight + .as_ref() + .is_some_and(|pending| { + pending.target.endpoint_id != self.active_endpoint_id + || !self.navigation_target_valid(&pending.target) + || self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.focused_workspace_id.as_deref() + == Some(pending.target.workspace_id.as_str()) + }) + }) + { + self.pending_workspace_highlight = None; + } + } + pub(super) fn navigation_target( &self, endpoint_id: &ClientEndpointId, @@ -140,11 +176,25 @@ impl ClientShellState { outcome.repaint = true; return; } + let action_index = outcome.actions.len(); if self.focus_or_activate( - target.endpoint_id, - ClientEndpointFocusTarget::Workspace(target.workspace_id), + target.endpoint_id.clone(), + ClientEndpointFocusTarget::Workspace(target.workspace_id.clone()), outcome, ) { + // Endpoint handoffs already retain their coherent source frame in the runtime. + // Only retain a highlight if a focus request was actually enqueued. + if let Some(ClientShellAction::Endpoint { request, .. }) = + outcome.actions.get(action_index) + { + self.pending_workspace_highlight = Some(PendingWorkspaceHighlight { + target, + request_id: request.id.clone(), + // A later focus can be coalesced with this one before a snapshot is sent. + expires_at: std::time::Instant::now() + std::time::Duration::from_secs(1), + }); + self.reconcile_pending_workspace_highlight(); + } self.mode = ClientShellMode::Terminal; self.navigate_workspace_id = None; } From 6c62707a3a43427a34fb418ce20c834a88089ba1 Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Sun, 20 Sep 2026 17:49:20 +0300 Subject: [PATCH 30/99] fix: preserve explicit worktree workspace membership (#4301) refs #4293 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> Co-authored-by: Can Celik <ogulcancelik@gmail.com> --- src/app/api/worktrees.rs | 110 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 4 deletions(-) diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 71f2e03a..f578f101 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -597,10 +597,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(|| { @@ -1484,6 +1484,108 @@ 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 = app.handle_api_request(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 = app.handle_api_request(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"); From 65927cef1c735169ba07677311ec9d6d7435c9f3 Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Sun, 20 Sep 2026 22:56:47 +0800 Subject: [PATCH 31/99] fix: drain event subscriptions and report history loss (#4225) * fix: drain event subscriptions and report history loss refs #4178 Root cause: Each subscription emits at most one matching event per 100 ms poll, while the shared event history retains only 512 entries. Bursts therefore build a backlog, and an evicted cursor silently resumes at the oldest retained event with no indication that earlier events were lost. Fix: Drain the bounded retained batch for lifecycle and agent-status streams. Check for history loss while holding the same lock used to read events, then return an events_lost error and close only the affected subscription. Keep event envelopes, request shapes, endpoint codecs and wait behavior unchanged. Preserve state filters, initial snapshots, event order and the existing cadence for snapshot-only subscriptions. Document resubscription and snapshot recovery in the unreleased API docs. Add regressions for burst draining, startup-window loss, retention bounds, status ordering and filtering, and unavailable history. Validation: Three socket regressions fail before the fix and pass afterward. Thirty-two focused API tests and five repeated rounds of socket tests pass. The real current-master comparison receives 516 of 600 events before the fix and 600 of 600 after it in about 0.97 seconds. A paused reader receives the explicit error, and a new subscription plus snapshot recovers successfully. Full native just ci on the dependency-inclusive branch passes 3631 Rust tests with six default skips, formatting, all-target Clippy, 112 maintenance tests, six architecture tests and 39 integration-asset tests. All seven docs contract tests pass. No retries or additional exclusions were used. Local Windows cross-lint was not run because its SDK is unavailable. * test: cover subscription isolation across platforms refs #4178 --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- .../src/content/docs/ja/socket-api.mdx | 10 +- .../website/src/content/docs/socket-api.mdx | 40 +- .../src/content/docs/zh-cn/socket-api.mdx | 10 +- src/api/event_hub.rs | 81 ++++ src/api/server.rs | 23 +- src/api/server/subscription_socket_tests.rs | 355 ++++++++++++++++++ src/api/subscriptions.rs | 255 ++++++++++--- 7 files changed, 717 insertions(+), 57 deletions(-) create mode 100644 src/api/server/subscription_socket_tests.rs diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index a556100c..09532d5e 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50 `workspace.move_block` は、順序付きの `workspace_ids` を `before_workspace_id` の前へアトミックに移動します。アンカーを省略するとブロックを末尾へ移動します。id は一意である必要があり、アンカーをブロックに含めることはできません。レスポンスにはサーバーが確定した順序付きワークスペース一覧が含まれます。 -`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。ブートストラップ中のイベントを取りこぼさないよう、まず別の接続で `events.subscribe` を開いて確認応答を待ち、そのストリームをバッファしながら `session.snapshot` を呼び出します。スナップショットを適用してから、バッファしたイベントを順番に適用し、そのままストリーミングを続けます。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。 +`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。キャッシュの初期化と復旧では、先に購読を開始し、後述のイベント購読セクションの手順でサーバーから現在の状態を読み取り、キャッシュを照合・更新します。イベントはキャッシュの無効化を知らせるものであり、スナップショットに無条件で再適用できるものではありません。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。 CLI の `herdr api snapshot` は、クライアントやエージェントが簡単にブートストラップできるよう、現在の `session.snapshot` レスポンスを JSON で出力します。 @@ -639,10 +639,16 @@ workspace の get/list 応答は結果の `tokens` マップを公開し、ス 最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。ライフサイクルイベントの購読はリクエストが受理された時点から始まり、それ以前に保持されていたイベントは再生しません。 +ライフサイクルとエージェント状態のイベントは、各購読内の順序を保った上限付きのバッチで送信されます。イベント履歴は永続ログではありません。購読の初期化中も含め、購読者が保持範囲より遅れた場合、サーバーは元のリクエスト `id` と `error.code: "events_lost"` を含むエラーレスポンスを送り、その購読接続を閉じます。欠落したイベントを通知せずに配信を続けることはありません。他のクライアント接続には影響しません。履歴はイベント種別間で共有されるため、失われたイベントが購読条件に一致しなかった可能性があっても、保持範囲を超えたことを通知します。 + +`events_lost` を受け取ったら、キャッシュを古い状態として扱います。新しい購読を開始して `subscription_started` を待ち、読み取りを続けながら別の接続で `session.snapshot` を取得してください。サーバーの現在の状態でキャッシュを照合し、スナップショットでキャッシュ全体を置き換えます。イベントはキャッシュの無効化通知として扱い、再取得の契機にします。取得は直列に行い、取得中にイベントが届いた場合は完了後に再取得してください。 + +スナップショットとイベントに共通のシーケンス境界はありません。順序が保証されるのは各購読項目内のみで、項目間やスナップショットとの前後関係は保証されません。バッファしたイベントの内容をスナップショットへ無条件に再適用しないでください。復旧できるのは現在の状態であり、失われたイベント履歴ではありません。再び `events_lost` を受け取った場合は、再購読して状態を照合し直します。読み取りが遅いクライアントでは、読み取り速度を上げるかイベントの負荷を減らす必要があります。 + ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。`workspace.reordered` は、アトミックに移動した `workspace_ids`、省略可能な `before_workspace_id`、サーバーが確定した順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。 タブのイベント購読には `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed`、`tab.moved` があります。`tab.moved` は、移動した `tab_id`、`workspace_id`、要求された `insert_index`、そのワークスペースの更新済みの順序付き `tabs` リストを含みます。 ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。`pane.focused` は、このサーバーに接続した任意のクライアントでの手動ペイン選択も報告します。他のクライアントの表示は移動させず、ペイロードにクライアントの識別情報は含みません。選択済みのペインを再度選んでも発行しません。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。 -レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でブートストラップするクライアントは、同じ `workspace_id` と `tab_id` のキャッシュ済みレイアウトを置き換えてください。 +レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でキャッシュを管理するクライアントは、このイベントを契機にサーバーから対象のレイアウトを再取得してください。遅れて届いたペイロードは、最新のスナップショットより古い可能性があります。 worktree のイベント購読には `worktree.created`、`worktree.opened`、`worktree.removed` があります。worktree イベントは Git チェックアウトのライフサイクルを記述します。`worktree.created` は開かれた `workspace` と作成された `worktree` を含みます。`worktree.opened` は対象の `workspace`、開かれた `worktree`、`already_open` を含みます。`worktree.removed` は `workspace_id`、削除された `worktree`、`forced` を含みます。 diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 84068cea..e6ae6a57 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -118,12 +118,11 @@ Raw socket method names use dot notation: `session.snapshot` returns a one-time bootstrap snapshot for clients that keep their own local runtime cache. The response includes version/protocol metadata, focused workspace/tab/pane ids, workspace records, tab records, pane records, -tab layout snapshots, and agent records. It is not a subscription. To avoid a -bootstrap gap, first open `events.subscribe` on another connection and wait for -its acknowledgement. Buffer that stream while calling `session.snapshot`, -install the snapshot, then apply the buffered events in order and continue -streaming. Call `session.snapshot` again after reconnecting or when the local -cache may be stale. Attached worktree provenance is included on workspace +tab layout snapshots, and agent records. It is not a subscription. For cache +bootstrap and recovery, subscribe first, then reconcile through authoritative +reads as described under Event subscriptions below. Events invalidate cached state; they +cannot be unconditionally replayed over a snapshot. Call `session.snapshot` +again after reconnecting or when the local cache may be stale. Attached worktree provenance is included on workspace records. Full repo worktree discovery remains `worktree.list`. From the CLI, `herdr api snapshot` prints the live `session.snapshot` response @@ -825,6 +824,29 @@ and retry rather than assuming the other entries were subscribed. Lifecycle subscriptions start when the request is accepted and do not replay events retained before that point. +Lifecycle and agent-status events are drained in bounded batches, in order within +each subscription. Event history is not durable. If a subscriber falls behind +the retained history, including while its subscriptions are being initialized, +the server sends an error response with the original request `id` and +`error.code: "events_lost"`, then closes that subscription connection instead of +silently continuing with missing events. Other clients remain connected. +The history is shared across event types, so an overrun is reported even when +the evicted events might not have matched this subscription's filters. + +On `events_lost`, treat the cached state as stale. Open a new subscription, +wait for `subscription_started`, and keep reading while requesting +`session.snapshot` on another connection. Use authoritative reads to reconcile +current state: replace the cache with the snapshot, and treat incoming events +as invalidation signals that trigger another read. Serialize refreshes and +refresh again if events arrive while a read is in flight. + +Snapshots and events have no shared sequence boundary. Ordering is guaranteed +within each subscription entry, not across entries or relative to a snapshot, +so do not unconditionally replay buffered event payloads onto the snapshot. +Recovery restores current state, not the missing event history. Another +`events_lost` requires resubscribing and reconciling again. Slow readers may +need to increase read throughput or reduce event load. + Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.reordered`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.reordered` includes the atomically moved `workspace_ids`, optional `before_workspace_id`, and authoritative ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal. Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`, `tab.renamed`, and `tab.moved`. `tab.moved` includes the moved `tab_id`, @@ -841,9 +863,9 @@ Terminal-title changes can emit `pane.updated`, but spinner-only raw-title chang `workspace_id`, and the current `scroll` metrics whenever Herdr observes a changed scroll snapshot. Layout event subscriptions include `layout.updated`. The event carries the -updated `PaneLayoutSnapshot` for one tab. Clients that bootstrap with -`session.snapshot` should replace the cached layout with the same -`workspace_id` and `tab_id`. +updated `PaneLayoutSnapshot` for one tab. Clients maintaining a cache with +`session.snapshot` should use this event to refresh the affected layout through +an authoritative read; a delayed payload may predate their latest snapshot. Worktree event subscriptions include `worktree.created`, `worktree.opened`, and `worktree.removed`. Worktree events describe Git checkout lifecycle. `worktree.created` includes the opened `workspace` and created `worktree`. `worktree.opened` includes the target `workspace`, opened `worktree`, and `already_open`. `worktree.removed` includes the `workspace_id`, removed `worktree`, and `forced`. diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index 43400eaa..3aae7131 100644 --- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx @@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50 `workspace.move_block` 会将有序的 `workspace_ids` 原子地移动到 `before_workspace_id` 之前;省略锚点则将该块移动到末尾。id 必须唯一,且锚点不能属于被移动的块。响应包含服务器确认的有序工作区列表。 -`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。为避免引导期间漏掉事件,应先在另一个连接上打开 `events.subscribe` 并等待确认,在调用 `session.snapshot` 时缓冲该事件流,然后安装快照、按顺序应用缓冲的事件并继续接收事件。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。 +`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。初始化或恢复缓存时,应先建立订阅,再按下文事件订阅部分的说明读取服务器当前状态并核对、更新缓存。事件表示缓存可能过期,不能无条件重放到快照上。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。 CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSON,让客户端和智能体可以简单地完成引导。 @@ -635,10 +635,16 @@ workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏 第一个响应确认订阅。之后的行是推送的事件。生命周期事件订阅从请求被接受时开始,不会重放在此之前保留的事件。 +生命周期和智能体状态事件按有界批次发送,每个订阅内保持事件顺序。事件历史不是持久日志。如果订阅者落后到保留范围之外,包括订阅初始化期间,服务器会发送包含原请求 `id` 和 `error.code: "events_lost"` 的错误响应,然后关闭该订阅连接,而不是静默跳过缺失事件后继续发送。其他客户端的连接不受影响。各类事件共享历史缓冲,因此即使被淘汰的事件可能不匹配此订阅的过滤条件,仍会报告溢出。 + +收到 `events_lost` 后,将缓存状态视为过期。建立新订阅并等待 `subscription_started`,然后持续读取,同时在另一条连接上请求 `session.snapshot`。通过服务器返回的当前状态重新核对缓存:用快照整体替换缓存,将收到的事件作为缓存失效通知并触发再次读取。串行执行刷新;如果读取期间又收到事件,完成后再次刷新。 + +快照和事件没有共同的序列边界。顺序仅在每个订阅条目内保证,不保证跨条目或相对于快照的顺序,因此不要把缓冲的事件载荷无条件重放到快照上。恢复得到的是当前状态,无法补回丢失的事件历史。再次收到 `events_lost` 时,需要重新订阅并核对状态。读取过慢的客户端可能需要提高读取速度或减少事件负载。 + 工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。`workspace.reordered` 包含原子移动的 `workspace_ids`、可选的 `before_workspace_id` 和服务器确认的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。 标签页事件订阅包括 `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed` 和 `tab.moved`。`tab.moved` 包含被移动的 `tab_id`、`workspace_id`、请求的 `insert_index` 和该工作区更新后的有序 `tabs` 列表。 窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。`pane.focused` 也会上报任意连接到此服务器的客户端手动选择窗格的变化,不会移动其他客户端的视图,且载荷不包含客户端身份。重复选择已选中的窗格不会再次发出事件。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。 -布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 引导的客户端应替换具有相同 `workspace_id` 和 `tab_id` 的缓存布局。 +布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 维护缓存的客户端应将该事件作为重新读取受影响布局的通知;延迟到达的载荷可能早于最新快照。 Worktree 事件订阅包括 `worktree.created`、`worktree.opened` 和 `worktree.removed`。Worktree 事件描述 Git 检出的生命周期。`worktree.created` 包含打开的 `workspace` 和创建的 `worktree`。`worktree.opened` 包含目标 `workspace`、打开的 `worktree` 和 `already_open`。`worktree.removed` 包含 `workspace_id`、被移除的 `worktree` 和 `forced`。 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<Vec<(u64, crate::api::schema::EventEnvelope)>, 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/server.rs b/src/api/server.rs index 03724813..44a2a0df 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -24,6 +24,9 @@ use crate::ipc::{ 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); pub(super) const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); @@ -749,7 +752,7 @@ fn stream_subscriptions( if let Err(err) = write_json_line( &mut stream, &SuccessResponse { - id: request_id, + id: request_id.clone(), result: ResponseResult::SubscriptionStarted {}, }, ) { @@ -765,7 +768,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(()); diff --git a/src/api/server/subscription_socket_tests.rs b/src/api/server/subscription_socket_tests.rs new file mode 100644 index 00000000..5cfbc706 --- /dev/null +++ b/src/api/server/subscription_socket_tests.rs @@ -0,0 +1,355 @@ +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<AtomicBool>, + api_tx: ApiRequestSender, + api_rx: mpsc::UnboundedReceiver<ApiRequestMessage>, + workers: Vec<std::thread::JoinHandle<io::Result<()>>>, + paths: Vec<PathBuf>, +} + +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<u8>, +} + +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<Value> { + // 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<usize>, 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, + 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..5be2d523 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<Vec<serde_json::Value>, 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<Vec<(u64, crate::api::schema::EventEnvelope)>, 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<Option<SubscriptionEventEnvelope>, 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<SubscriptionEventEnvelope> { + 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<Option<SubscriptionEventEnvelope>, 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 { @@ -687,6 +768,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::<Vec<_>>(); + 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 { From f10df75c8a5f1b5e5f90689c3a8ceb6758366e05 Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Sun, 20 Sep 2026 18:12:12 +0300 Subject: [PATCH 32/99] fix: reject terminal-less attach before starting a session (#4395) refs #4393 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- src/server/autodetect.rs | 8 +++++ tests/auto_detect.rs | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index e6fdaaa4..afd8edfd 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -293,6 +293,14 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu /// 2. If no server → spawn server daemon → wait for socket readiness /// 3. Run the thin client (which connects to the server) pub fn auto_detect_launch(saved_federation: bool) -> io::Result<()> { + // The client requires terminal geometry before it can attach. Reject an + // unusable terminal before socket lookup creates directories or starts a daemon. + crate::platform::terminal_grid_size().map_err(|err| { + io::Error::new( + err.kind(), + format!("cannot attach without a usable terminal: {err}; run inside a terminal"), + ) + })?; let socket_path = client_socket_path(); info!(path = %socket_path.display(), "auto-detect launch starting"); diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs index ae902dac..deb01bab 100644 --- a/tests/auto_detect.rs +++ b/tests/auto_detect.rs @@ -237,6 +237,73 @@ fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool { // Tests // --------------------------------------------------------------------------- +/// A rejected noninteractive attach must not create a session or start its daemon. +#[test] +fn session_attach_without_terminal_leaves_no_session() { + use std::os::unix::process::CommandExt as _; + + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + register_runtime_dir(&runtime_dir); + let name = "no-tty"; + let app_dir = if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }; + let session_dir = config_home.join(app_dir).join("sessions").join(name); + let run = |args: &[&str]| { + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command + .args(args) + .env("XDG_CONFIG_HOME", &config_home) + .env("XDG_RUNTIME_DIR", &runtime_dir) + .env_remove("HERDR_CONFIG_PATH") + .env_remove("HERDR_ENV") + .env_remove("HERDR_SESSION") + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_WORKSPACE_ID") + .env_remove("HERDR_TAB_ID") + .env_remove("HERDR_PANE_ID"); + // Piped stdio alone can still leave /dev/tty usable. Match noninteractive + // SSH by detaching the child from the test runner's controlling terminal. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + command.output().unwrap() + }; + let before = run(&["session", "list", "--json"]); + let output = run(&["session", "attach", name]); + let session_created = session_dir.exists(); + let after = run(&["session", "list", "--json"]); + // Clean up even when a regression started a daemon, before assertions panic. + cleanup_test_base(&base); + + assert!( + !session_created, + "failed attach created a session directory" + ); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("terminal"), "stderr={stderr}"); + assert!(stderr.contains("run inside a terminal"), "stderr={stderr}"); + assert!( + output.stdout.is_empty(), + "failed attach emitted terminal output" + ); + assert!(before.status.success()); + assert!(after.status.success()); + assert_eq!(before.stdout, after.stdout, "session inventory changed"); +} + /// Running `herdr` with no server present starts a server /// and attaches as client. #[test] From f090ce95d05d2e7811869be93f9a23bf4ac2c3af Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Mon, 21 Sep 2026 01:33:11 +0800 Subject: [PATCH 33/99] fix: confirm closing the last tab in the tui (#4409) * fix: confirm closing the last tab in the tui refs #4379 * docs: clarify last-tab worktree confirmation in translations refs #4379 --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- .../src/content/docs/cli-reference.mdx | 2 + .../src/content/docs/ja/cli-reference.mdx | 4 +- .../src/content/docs/zh-cn/cli-reference.mdx | 4 +- src/client/shell/actions.rs | 13 +- src/client/shell/context_menu.rs | 2 +- src/client/shell/mouse.rs | 15 +- src/client/shell/overlay_input.rs | 91 +++++-- src/client/shell/state.rs | 7 + src/client/shell/tests/close_tab.rs | 241 ++++++++++++++++++ src/client/shell/tests/graphics.rs | 1 + src/client/shell/tests/mod.rs | 1 + 11 files changed, 346 insertions(+), 35 deletions(-) create mode 100644 src/client/shell/tests/close_tab.rs diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 9f61d02d..cf9b355f 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -197,6 +197,8 @@ herdr tab close <tab_id> A tab is another terminal layout inside a workspace. Without `--workspace`, `tab create` uses the active workspace and fails if none exists. Its JSON response exposes `.result.tab.tab_id` and `.result.root_pane.pane_id`. Closing a workspace's last tab also closes the workspace, matching the TUI close-tab action. If `confirm_close` is enabled and closing the tab would also close a whole worktree group, `tab close` returns a `confirmation_required` error instead. +With `ui.confirm_close` enabled, the TUI asks for confirmation before closing a workspace's last tab from a keybinding or the tab menu. Ordinary CLI/API tab closes remain immediate. + Workspace and tab creation, and pane splitting, leave focus unchanged by default. `--focus` selects the new layout; `--no-focus` states the default explicitly. Without `--cwd`, new terminals follow the configured `terminal.new_cwd` policy, which follows the source pane or workspace by default. Each `--env KEY=VALUE` adds or replaces that variable in the new root shell. ## Panes diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index fbda5674..99a2e133 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -191,7 +191,9 @@ herdr tab rename <tab_id> <label> herdr tab close <tab_id> ``` -タブはワークスペース内の別のターミナルレイアウトです。`--workspace` を省略すると、`tab create` はアクティブなワークスペースを使い、存在しなければ失敗します。JSON レスポンスの `.result.tab.tab_id` と `.result.root_pane.pane_id` から ID を取得できます。 +タブはワークスペース内の別のターミナルレイアウトです。`--workspace` を省略すると、`tab create` はアクティブなワークスペースを使い、存在しなければ失敗します。JSON レスポンスの `.result.tab.tab_id` と `.result.root_pane.pane_id` から ID を取得できます。ワークスペースの最後のタブを閉じると、そのワークスペースも閉じられます。`ui.confirm_close` が有効で、親ワークツリーの最後のタブを閉じるとワークツリーグループ全体が閉じられる場合、`tab close` は `confirmation_required` エラーを返します。 + +`ui.confirm_close` が有効な場合、キーバインドやタブメニューからワークスペースの最後のタブを閉じる前に、TUI が確認を求めます。通常の CLI/API によるタブの終了は引き続き即座に実行されます。 ワークスペースとタブの作成、およびペインの分割は、デフォルトではフォーカスを変えません。`--focus` は新しいレイアウトを選択し、`--no-focus` はデフォルトを明示します。`--cwd` がない場合、新しいターミナルは `terminal.new_cwd` の設定に従い、デフォルトでは元のペインまたはワークスペースを引き継ぎます。各 `--env KEY=VALUE` は新しいルートシェルの環境変数を追加または置換します。 diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 4c7df02a..011efbd3 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -191,7 +191,9 @@ herdr tab rename <tab_id> <label> herdr tab close <tab_id> ``` -标签页是工作区内的另一个终端布局。不指定 `--workspace` 时,`tab create` 使用活动工作区;如果没有活动工作区则失败。JSON 响应中的 `.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。 +标签页是工作区内的另一个终端布局。不指定 `--workspace` 时,`tab create` 使用活动工作区;如果没有活动工作区则失败。JSON 响应中的 `.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。关闭工作区的最后一个标签页也会关闭该工作区。启用 `ui.confirm_close` 时,如果关闭父工作树的最后一个标签页会连带关闭整个工作树组,`tab close` 会返回 `confirmation_required` 错误。 + +启用 `ui.confirm_close` 时,通过快捷键或标签页菜单关闭工作区的最后一个标签页前,TUI 会要求确认。普通的 CLI/API 标签页关闭操作仍立即执行。 创建工作区、标签页和拆分窗格默认不改变焦点。`--focus` 选择新布局;`--no-focus` 显式表达默认行为。不指定 `--cwd` 时,新终端遵循 `terminal.new_cwd` 设置,默认跟随来源窗格或工作区。每个 `--env KEY=VALUE` 在新的根 shell 中添加或替换一个环境变量。 diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index da4edd49..9d0a3bd5 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -124,6 +124,16 @@ impl ClientShellState { outcome.repaint = true; return; } + if action == crate::input::KeybindAction::CloseTab { + if let Some(tab_id) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_tab_id.clone()) + { + self.request_tab_close(tab_id, outcome); + } + return; + } if action == crate::input::KeybindAction::NewTab && self.config.prompt_new_tab_name { self.open_new_tab_overlay(); @@ -1040,9 +1050,6 @@ impl ClientShellState { env: Default::default(), })) } - KeybindAction::CloseTab => Some(Method::TabClose(TabTarget { - tab_id: focused_tab?, - })), KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget { pane_id: focused_pane.clone()?, })), diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs index 96f57178..b57bc843 100644 --- a/src/client/shell/context_menu.rs +++ b/src/client/shell/context_menu.rs @@ -358,7 +358,7 @@ impl ClientShellState { } } ClientContextMenuAction::Close => { - self.push_endpoint_method(Method::TabClose(TabTarget { tab_id }), outcome); + self.request_tab_close(tab_id, outcome); } _ => {} } diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index a7e0dd81..f2df2941 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1692,20 +1692,7 @@ impl ClientShellState { match self.overlay.as_ref() { Some(ClientShellOverlay::Rename(_)) => self.save_rename_overlay(outcome), Some(ClientShellOverlay::ConfirmClose(_)) => { - let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() - else { - return; - }; - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceClose( - crate::api::schema::WorkspaceCloseParams { - workspace_id: confirm.workspace_id, - close_group: true, - }, - ), - outcome, - ); - outcome.repaint = true; + self.accept_close_confirmation(outcome); } _ => {} } diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index e4248304..10bd4a45 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -877,19 +877,7 @@ impl ClientShellState { if matches!(self.overlay, Some(ClientShellOverlay::ConfirmClose(_))) { if key.code == KeyCode::Enter { - let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { - return; - }; - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceClose( - crate::api::schema::WorkspaceCloseParams { - workspace_id: confirm.workspace_id, - close_group: true, - }, - ), - outcome, - ); - outcome.repaint = true; + self.accept_close_confirmation(outcome); } else if key.code == KeyCode::Esc { self.overlay = None; self.mode = ClientShellMode::Navigate; @@ -999,16 +987,74 @@ impl ClientShellState { outcome.repaint = true; } + pub(super) fn request_tab_close(&mut self, tab_id: String, outcome: &mut ClientShellInput) { + let workspace_id = self.snapshot.as_deref().and_then(|snapshot| { + let target = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)?; + (self.config.confirm_close + && !snapshot + .tabs + .iter() + .any(|tab| tab.workspace_id == target.workspace_id && tab.tab_id != tab_id)) + .then(|| target.workspace_id.clone()) + }); + if let Some(workspace_id) = workspace_id { + if self.open_close_confirmation(workspace_id, Some(tab_id.clone())) { + outcome.repaint = true; + return; + } + } + self.push_endpoint_method( + crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { tab_id }), + outcome, + ); + } + + pub(super) fn accept_close_confirmation(&mut self, outcome: &mut ClientShellInput) { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() else { + return; + }; + outcome.repaint = true; + let method = if let Some(target) = confirm.tab_target { + if target.workspace.endpoint_id != self.active_endpoint_id + || !self.navigation_target_valid(&target.workspace) + || !self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.tabs.iter().any(|tab| { + tab.tab_id == target.tab_id + && tab.workspace_id == target.workspace.workspace_id + }) + }) + { + self.receive_endpoint_unavailable( + "Close target changed; try closing the tab again".into(), + ); + return; + } + crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { + tab_id: target.tab_id, + }) + } else { + crate::api::schema::Method::WorkspaceClose(crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }) + }; + self.push_endpoint_method(method, outcome); + } + pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) { + self.open_close_confirmation(workspace_id, None); + } + + fn open_close_confirmation(&mut self, workspace_id: String, tab_id: Option<String>) -> bool { let Some(snapshot) = self.snapshot.as_deref() else { - return; + return false; }; let Some(workspace) = snapshot .workspaces .iter() .find(|workspace| workspace.workspace_id == workspace_id) else { - return; + return false; }; let group_key = workspace .worktree @@ -1030,6 +1076,19 @@ impl ClientShellState { }) .unwrap_or_else(|| vec![workspace]); let closes_group = group.len() > 1; + // Keep parent-group tab closes on the existing server confirmation path. + if tab_id.is_some() && closes_group { + return false; + } + let tab_target = if let Some(tab_id) = tab_id { + let Some(workspace) = self.navigation_target(&self.active_endpoint_id, &workspace_id) + else { + return false; + }; + Some(ClientTabCloseConfirmation { tab_id, workspace }) + } else { + None + }; let pane_count = group .iter() .map(|member| { @@ -1053,6 +1112,7 @@ impl ClientShellState { self.overlay = Some(ClientShellOverlay::ConfirmClose( ClientConfirmCloseOverlay { workspace_id, + tab_target, title: if closes_group { "Close worktree group?".to_owned() } else { @@ -1061,5 +1121,6 @@ impl ClientShellState { detail: format!("{} — {scope}", workspace.label), }, )); + true } } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 55a065d5..2eaf18bf 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -562,9 +562,16 @@ pub(super) struct ClientContextMenuItem { pub(super) action: ClientContextMenuAction, } +#[derive(Debug)] +pub(super) struct ClientTabCloseConfirmation { + pub(super) tab_id: String, + pub(super) workspace: WorkspaceNavigationTarget, +} + #[derive(Debug)] pub(super) struct ClientConfirmCloseOverlay { pub(super) workspace_id: String, + pub(super) tab_target: Option<ClientTabCloseConfirmation>, pub(super) title: String, pub(super) detail: String, } diff --git a/src/client/shell/tests/close_tab.rs b/src/client/shell/tests/close_tab.rs new file mode 100644 index 00000000..d5e897a3 --- /dev/null +++ b/src/client/shell/tests/close_tab.rs @@ -0,0 +1,241 @@ +use super::*; +use crate::api::schema::Method; +use crossterm::event::{MouseButton, MouseEventKind}; + +fn close_state(confirm: bool, tab_count: usize) -> ClientShellState { + let mut projected = snapshot(); + for number in 2..=tab_count { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{number}"); + tab.number = number; + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.confirm_close = confirm; + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).unwrap(); + state +} + +fn click(state: &mut ClientShellState, rect: Rect) -> ClientShellInput { + state.handle_raw_events(vec![crate::raw_input::RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + })]) +} + +fn request_close(state: &mut ClientShellState, menu: bool) -> ClientShellInput { + if menu { + state.open_tab_context_menu("tab_1".into(), 30, 1); + state.compose(106, 24).unwrap(); + let close_row = state.hits.context_menu_rows[2].0; + click(state, close_row) + } else { + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseTab), + &mut outcome, + ); + outcome + } +} + +fn assert_no_close(outcome: &ClientShellInput) { + assert!(outcome.requests.is_empty()); + assert!(outcome.actions.iter().all(|action| { + !matches!(action, ClientShellAction::Endpoint { request, .. } + if matches!(request.method, Method::TabClose(_) | Method::WorkspaceClose(_))) + })); +} + +fn assert_tab_close(outcome: &ClientShellInput) { + let methods = outcome + .actions + .iter() + .filter_map(|action| match action { + ClientShellAction::Endpoint { request, .. } => Some(&request.method), + _ => None, + }) + .filter(|method| !matches!(method, Method::TabFocus(_))) + .collect::<Vec<_>>(); + assert!(matches!(methods.as_slice(), [Method::TabClose(target)] if target.tab_id == "tab_1")); +} + +#[test] +fn last_tab_close_waits_for_keyboard_or_mouse_confirmation() { + for menu in [false, true] { + let mut state = close_state(true, 1); + let requested = request_close(&mut state, menu); + assert_no_close(&requested); + assert!(requested.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let frame = state.compose(106, 24).unwrap(); + let text = frame_rows(&frame).join("\n"); + assert!(text.contains("Close workspace?")); + assert!(text.contains("1 pane")); + let accepted = if menu { + let primary = state.hits.overlay_primary; + click(&mut state, primary) + } else { + state.handle_input_bytes(b"\r") + }; + assert_tab_close(&accepted); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn last_tab_close_confirmation_can_be_cancelled() { + for mouse in [false, true] { + let mut state = close_state(true, 1); + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + state.compose(106, 24).unwrap(); + let cancelled = if mouse { + let cancel = state.hits.overlay_cancel; + click(&mut state, cancel) + } else { + state.handle_input_bytes(b"\x1b") + }; + assert_no_close(&cancelled); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn tab_close_stays_immediate_with_confirmation_disabled_or_other_tabs() { + for (confirm, tabs) in [(false, 1), (false, 2), (true, 2)] { + for menu in [false, true] { + let mut state = close_state(confirm, tabs); + assert_tab_close(&request_close(&mut state, menu)); + assert!(state.overlay.is_none()); + } + } +} + +#[test] +fn last_tab_confirmation_preserves_target_across_focus_changes_and_new_tabs() { + let mut state = close_state(true, 1); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + let mut other_workspace = projected.workspaces[0].clone(); + other_workspace.workspace_id = "ws_2".into(); + other_workspace.active_tab_id = "other_tab".into(); + other_workspace.focused = false; + projected.workspaces.push(other_workspace); + let mut other_tab = projected.tabs[0].clone(); + other_tab.workspace_id = "ws_2".into(); + other_tab.tab_id = "other_tab".into(); + other_tab.focused = false; + projected.tabs.push(other_tab); + state.set_snapshot(Box::new(projected.clone())); + + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let mut new_tab = projected.tabs[0].clone(); + new_tab.tab_id = "new_tab".into(); + new_tab.focused = false; + projected.tabs.push(new_tab); + projected.focused_workspace_id = Some("ws_2".into()); + projected.focused_tab_id = Some("other_tab".into()); + for workspace in &mut projected.workspaces { + workspace.focused = workspace.workspace_id == "ws_2"; + } + for tab in &mut projected.tabs { + tab.focused = tab.tab_id == "other_tab"; + } + state.set_snapshot(Box::new(projected)); + assert_tab_close(&state.handle_input_bytes(b"\r")); +} + +#[test] +fn last_tab_confirmation_rejects_missing_moved_or_reconnected_targets() { + for change in ["missing", "moved", "reconnected"] { + let mut state = close_state(true, 1); + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + match change { + "missing" => projected.tabs.clear(), + "moved" => projected.tabs[0].workspace_id = "different_workspace".into(), + "reconnected" => state.endpoints[0].snapshot_generation = Some(2), + _ => unreachable!(), + } + if change != "reconnected" { + state.set_snapshot(Box::new(projected)); + } + assert_no_close(&state.handle_input_bytes(b"\r")); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn last_tab_close_preserves_parent_group_and_linked_workspace_scope() { + for linked in [false, true] { + let mut state = close_state(true, 1); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: linked, + }); + let mut sibling = projected.workspaces[0].clone(); + sibling.workspace_id = "ws_2".into(); + sibling.active_tab_id = "tab_2".into(); + sibling.focused = false; + sibling.worktree.as_mut().unwrap().is_linked_worktree = !linked; + projected.workspaces.push(sibling); + let mut tab = projected.tabs[0].clone(); + tab.tab_id = "tab_2".into(); + tab.workspace_id = "ws_2".into(); + tab.focused = false; + projected.tabs.push(tab); + state.set_snapshot(Box::new(projected)); + + let close = request_close(&mut state, false); + if linked { + assert_no_close(&close); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + assert_tab_close(&state.handle_input_bytes(b"\r")); + } else { + assert_tab_close(&close); + assert!(state.overlay.is_none()); + let [ClientShellAction::Endpoint { request, .. }] = close.actions.as_slice() else { + panic!("tab close request"); + }; + state.handle_endpoint_result( + "boot-1", + &request.id, + Err(ClientShellEndpointError { + code: Some("confirmation_required".into()), + message: "closing this tab would close a worktree group".into(), + }), + ); + assert!(matches!(state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(confirm)) if confirm.title == "Close worktree group?")); + let accepted = state.handle_input_bytes(b"\r"); + assert!(matches!(accepted.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, Method::WorkspaceClose(params) + if params.workspace_id == "ws_1" && params.close_group))); + } + } +} diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs index 98fc62fa..240e031c 100644 --- a/src/client/shell/tests/graphics.rs +++ b/src/client/shell/tests/graphics.rs @@ -216,6 +216,7 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { }), ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { workspace_id: "ws_1".into(), + tab_target: None, title: "close".into(), detail: "confirm".into(), }), diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 787d8a75..9f04b527 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -246,6 +246,7 @@ fn surface_with_popup() -> PaneSurfaceFrame { mod agents_worktrees_notifications; mod chrome_context; +mod close_tab; mod copy; mod endpoint_requests; mod endpoints; From 0ff0f27e222633c97ba4291f6b9be4137002ca84 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Mon, 21 Sep 2026 00:29:45 +0300 Subject: [PATCH 34/99] fix: preserve session layouts across shutdown and restore failures (#4400) * fix: preserve session layouts across shutdown and restore failures refs #4320 * test: update subscription fixture for pane restore errors --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- Cargo.lock | 339 +++++++++++++++++- Cargo.toml | 5 + docs/next/api/herdr-api.schema.json | 12 + .../src/content/docs/ja/session-state.mdx | 23 +- .../src/content/docs/session-state.mdx | 23 +- .../src/content/docs/zh-cn/session-state.mdx | 23 +- src/api/schema/panes.rs | 2 + src/api/schema/tests.rs | 2 + src/api/server.rs | 1 + src/api/server/subscription_socket_tests.rs | 1 + src/api/subscriptions.rs | 1 + src/app/agent_resume.rs | 58 ++- src/app/creation.rs | 1 + src/app/session.rs | 6 +- src/client/shell/tests/mod.rs | 1 + src/pane.rs | 53 +++ src/persist/io.rs | 1 + src/persist/restore.rs | 205 ++++++++++- src/persist/snapshot.rs | 173 ++++++++- src/persist/writer.rs | 239 +++++++++++- src/platform/linux.rs | 3 + src/platform/linux/shutdown.rs | 208 +++++++++++ src/platform/mod.rs | 30 ++ src/server/headless.rs | 20 +- src/server/headless/notifications.rs | 6 + src/server/headless/tests/mod.rs | 32 ++ src/terminal/runtime.rs | 4 + src/terminal/state.rs | 2 + src/ui/panes.rs | 42 ++- tests/client_mode.rs | 57 ++- tests/host_shutdown.rs | 221 ++++++++++++ 31 files changed, 1722 insertions(+), 72 deletions(-) create mode 100644 src/platform/linux/shutdown.rs create mode 100644 tests/host_shutdown.rs diff --git a/Cargo.lock b/Cargo.lock index 0ba46fb6..7cac0169 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" @@ -680,6 +780,7 @@ dependencies = [ "clap_complete", "crossterm", "ctrlc", + "futures-util", "interprocess", "jsonc-parser", "libc", @@ -702,6 +803,7 @@ dependencies = [ "widestring", "windows-sys", "wmi", + "zbus", ] [[package]] @@ -1072,6 +1174,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" @@ -1250,6 +1368,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" @@ -1579,6 +1706,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" @@ -1680,6 +1818,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" @@ -1735,6 +1883,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" @@ -1891,7 +2063,9 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", + "tracing", "windows-sys", ] @@ -1914,8 +2088,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -1927,6 +2101,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" @@ -1936,9 +2119,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]] @@ -2020,6 +2224,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" @@ -2076,6 +2291,7 @@ dependencies = [ "atomic", "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2437,6 +2653,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" @@ -2548,8 +2773,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 8c27a543..0c18ec55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,11 @@ schemars = { version = "1.2.1", features = ["derive"] } [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" diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 6f8b4f20..ea888dc3 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, @@ -8005,6 +8011,12 @@ "pane_id": { "type": "string" }, + "restore_error": { + "type": [ + "string", + "null" + ] + }, "revision": { "format": "uint64", "minimum": 0, diff --git a/docs/next/website/src/content/docs/ja/session-state.mdx b/docs/next/website/src/content/docs/ja/session-state.mdx index 006ba439..4f16bda6 100644 --- a/docs/next/website/src/content/docs/ja/session-state.mdx +++ b/docs/next/website/src/content/docs/ja/session-state.mdx @@ -34,10 +34,29 @@ Herdr サーバーが停止して再起動すると、元のペインのプロ スナップショット復元は、動作中のシェル、サーバー、テスト、その他任意のプロセスを保存しません。より強力な復元経路が使えないペインは、保存されたディレクトリで新しいシェルとして戻ってきます。 -`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。正常に読み込めた場合は復旧用コピーを作成しません。 +保存されたディレクトリが使えない場合やシェルを起動できない場合、ペインは消えたりホームディレクトリへ移動したりせず、エラーを表示してレイアウトに残ります。保存されたディレクトリとエージェントのセッション参照は保持されます。ディレクトリやシェル設定を修正してサーバーを再起動すると再試行できます。不要なペインは明示的に閉じて削除できます。 + +保存時には、OS が取得をサポートしていれば、実行中のシェルのディレクトリを優先します。シェル終了後も最後に確認したディレクトリを保持します。それ以外の環境ではシェルが報告したディレクトリを使用します。 + +`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。これらの読み込み失敗時の復旧用コピーは、通常のスナップショット履歴とは別に管理します。 復旧用コピーは最新の 3 個を保持し、新しいコピーの安全な保存が完了してから古いコピーを削除します。コピーに失敗すると、自動保存時も終了時も元のファイルを変更せず、失敗をログに記録し、次の保存要求時に再試行します。コピーが自動的に復元されることはありません。復旧するには、対象のサーバーを停止し、復旧用ファイルをそのサーバーの `session.json` にコピーしてから再起動します。復旧用コピーにペイン画面履歴は含まれません。 +## シャットダウンとスナップショットからの復旧 + +systemd-logind を使う Linux では、Herdr はホストのシャットダウン予告を受け取り、保存して停止するまで短い遅延を要求します。その後 logind がシャットダウンを進めます。遅延には OS による上限があり、無期限にはブロックしません。logind がない環境、強制終了、電源断、予告前にプロセスが終了した場合には、この保護は使えません。 + +全プラットフォームで、`session.json` の隣の `session-snapshots/` に最大 48 個のレイアウトスナップショットを保持します。最初に保存したレイアウトは直ちにコピーします。その後の保存や削除では、直前の保存済みレイアウトを最大 15 分に 1 回コピーします。同じ内容は重複保存しません。この間隔はサーバーの再起動後も維持されるため、連続したペイン終了や再起動で古いコピーがすべて押し出されることはありません。直近の変更がコピーに含まれない場合があります。 + +通常のペイン終了は引き続き `session.json` に反映されます。復旧を促す通知や、古いスナップショットの自動復元はありません。手動で復旧するには: + +1. `herdr session list --json` で対象のセッションディレクトリを確認します。 +2. 対象サーバーを停止します。デフォルトは `herdr server stop`、名前付きセッションは `herdr session stop <name>` です。 +3. 現在の `session.json` を別にコピーしてから、`session-snapshots/` 内の選んだファイルを `session.json` にコピーします。ファイルの更新日時で保存時刻を確認できます。 +4. セッションを再起動します。 + +コピーにはレイアウトとエージェントのセッション参照が含まれますが、実行中のプロセスやペイン画面履歴は含まれません。非公開のセッションデータとして扱ってください。コピーの書き込みに失敗するとログに記録しますが、通常のセッション保存は続行します。 + ## ペイン画面履歴のリプレイ ペイン画面履歴は、サーバーの完全な再起動後に直近のターミナル内容を復元します。復元されるのは Herdr が表示できるものであって、元のプロセスではありません。 @@ -51,6 +70,8 @@ pane_history = true 有効にすると、Herdr は保存したペイン履歴を `session.json` の隣の `session-history.json` に保存します。Herdr の設定/セッションディレクトリはターミナル履歴と同じ感覚で扱ってください。 +履歴は、保存されたレイアウトと完全に一致する場合だけ再生されます。スナップショットを手動で復元した場合など、異なるレイアウトの履歴は無視されます。レイアウトを検証できない旧形式の履歴も無視されますが、新たに保存された履歴は次回の再起動で再生できます。 + ## エージェントネイティブのセッション復元 一部のエージェントは自分の会話セッションを resume できます。Herdr は、公式インテグレーションが報告したセッション参照を使って、Herdr サーバーの再起動後に対応エージェントのペインを再起動できます。 diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index fd065483..b8706cd5 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -32,10 +32,29 @@ If the Herdr server stops and starts again, the original pane processes are gone Snapshot restore does not preserve running shells, servers, tests, or arbitrary processes. Panes that cannot use a stronger restore path come back as new shells in their saved directories. -If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. Healthy restores do not create recovery copies. +If a saved directory is unavailable or the shell cannot start, the pane stays in the layout with an error instead of disappearing or moving to your home directory. Its saved directory and agent session reference remain intact. Fix the directory or shell configuration and restart the server to retry, or explicitly close the pane to remove it. + +When saving, Herdr prefers the live shell's directory where the operating system exposes it, and retains the last confirmed directory after the shell exits. Shell-reported directories remain the fallback on other platforms. + +If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. These failed-load recovery copies are separate from normal snapshot history. Herdr retains the three most recent recovery copies, pruning older copies only after a new copy is safely written. If preservation fails, autosave and shutdown leave the original untouched and log the failure; preservation is retried on the next save request. Copies are never restored automatically. To recover one, stop the affected server, copy the recovery file over its `session.json`, then restart. Recovery copies do not include pane screen history. +## Shutdown and snapshot recovery + +On Linux systems with systemd-logind, Herdr listens for the host shutdown warning and requests a short delay while it saves and stops. This happens before logind proceeds with shutdown. The operating system limits the delay; Herdr does not block shutdown indefinitely. This protection is unavailable without logind and does not cover forced shutdown, power loss, or processes killed before the warning arrives. + +Herdr also keeps up to 48 layout snapshots in `session-snapshots/` beside `session.json`, on all platforms. The first saved layout is copied immediately. Later saves or clears can add a snapshot of the previous saved layout, at most once every 15 minutes. Unchanged snapshots are not duplicated. The interval survives server restarts, so a burst of pane exits or restarts cannot rotate away all older layouts. Recent changes may not be in the recovery snapshots. + +Normal pane closures still update `session.json`. There are no recovery prompts and no automatic restoration of older snapshots. To recover a layout: + +1. Find the affected session directory with `herdr session list --json`. +2. Stop that server (`herdr server stop` for the default session, or `herdr session stop <name>` for a named session). +3. Keep a copy of its current `session.json`, then copy the chosen file from `session-snapshots/` over `session.json`. File modification times identify when snapshots were taken. +4. Start the session again. + +Snapshots contain layout and agent session references, not running processes or pane screen history. Treat them as private session data. A snapshot-write failure is logged but does not prevent the primary session from saving. + ## Pane screen history replay Pane screen history restores recent terminal contents after a full server restart without restoring the old process. @@ -49,6 +68,8 @@ pane_history = true When enabled, Herdr stores saved pane history in `session-history.json` next to `session.json`. Treat the Herdr config/session directory like terminal history. +History is replayed only when it matches the exact saved layout. History from a different layout, including after manual snapshot recovery, is ignored. Older history files without layout verification are also ignored; newly saved history can be replayed on the next restart. + ## Native agent session restore Some agents can resume their own conversation sessions. Herdr can use official integration-reported session references to restart supported agent panes after a Herdr server restart. diff --git a/docs/next/website/src/content/docs/zh-cn/session-state.mdx b/docs/next/website/src/content/docs/zh-cn/session-state.mdx index c4c4a387..9be84060 100644 --- a/docs/next/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/next/website/src/content/docs/zh-cn/session-state.mdx @@ -34,10 +34,29 @@ herdr 快照恢复不会保留运行中的 shell、服务器、测试或任意进程。无法使用更强恢复路径的窗格,会在各自保存的目录中作为新 shell 回来。 -如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。正常加载的会话不会创建恢复副本。 +如果保存的目录不可用或 shell 无法启动,窗格会留在布局中并显示错误,而不是消失或切换到主目录。保存的目录和智能体会话引用仍会保留。修复目录或 shell 配置后,重启服务器即可重试;也可以明确关闭窗格将其移除。 + +保存时,如果操作系统支持查询,Herdr 会优先使用正在运行的 shell 的目录,并在 shell 退出后保留最后确认的目录。在其他平台上,则使用 shell 报告的目录。 + +如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。这些加载失败时的恢复副本与常规快照历史分开管理。 Herdr 保留最新的三个恢复副本,只有在新副本安全写入后才删除旧副本。如果无法保存副本,自动保存和退出保存都不会修改原始文件,并会记录错误,在下一次保存请求时重试。副本不会自动恢复。如需恢复,请停止对应的服务器,将恢复文件复制到该服务器的 `session.json`,然后重启。恢复副本不包含窗格屏幕历史。 +## 关机与快照恢复 + +在使用 systemd-logind 的 Linux 系统上,Herdr 会监听主机关机预告,并请求短暂延迟,以便保存并停止服务器,随后 logind 才继续关机。延迟由操作系统限制,不会无限阻止关机。此保护不适用于没有 logind 的系统、强制关机、断电,或进程在预告到达前已被终止的情况。 + +在所有平台上,Herdr 还会在 `session.json` 旁的 `session-snapshots/` 中保留最多 48 个布局快照。首次保存的布局会立即复制。之后的保存或清除操作会复制之前保存的布局,最多每 15 分钟一次,不重复保存相同内容。此间隔在服务器重启后仍然有效,因此连续的窗格退出或重启不会挤掉所有旧快照。最近的更改可能尚未包含在快照中。 + +正常关闭窗格仍会更新 `session.json`,不会出现恢复提示,也不会自动恢复旧布局。如需手动恢复: + +1. 用 `herdr session list --json` 找到对应的会话目录。 +2. 停止对应的服务器:默认会话用 `herdr server stop`,命名会话用 `herdr session stop <name>`。 +3. 先另存当前的 `session.json`,再将 `session-snapshots/` 中选定的文件复制到 `session.json`。文件修改时间表示快照的保存时间。 +4. 重新启动该会话。 + +快照包含布局和代理会话引用,不包含运行中的进程或窗格屏幕历史。请将其视为私密会话数据。快照写入失败会记录到日志,但不会阻止主会话文件的正常保存。 + ## 窗格屏幕历史回放 窗格屏幕历史在服务器完全重启后恢复最近的终端内容。它恢复的是 Herdr 能展示的内容,而不是原来的进程。 @@ -51,6 +70,8 @@ pane_history = true 开启后,Herdr 把保存的窗格历史存放在 `session.json` 旁边的 `session-history.json` 中。请像对待终端历史一样对待 Herdr 的配置/会话目录。 +只有与保存的布局完全匹配的历史才会回放。来自其他布局的历史,包括手动恢复快照后不再匹配的历史,都会被忽略。无法验证布局的旧版历史文件也会被忽略;新保存的历史可在下次重启时回放。 + ## 智能体原生会话恢复 一些智能体可以恢复它们自己的对话会话。Herdr 可以使用官方集成上报的会话引用,在 Herdr 服务器重启后重新启动受支持的智能体窗格。 diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 6cbcaf43..67d5185e 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -535,6 +535,8 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub foreground_cwd: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub restore_error: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index d51c565d..4f15b322 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -825,6 +825,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, @@ -1253,6 +1254,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 44a2a0df..23817fff 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -1037,6 +1037,7 @@ mod tests { focused: true, cwd: None, foreground_cwd: None, + restore_error: None, label: None, agent: Some("pi".into()), title: None, diff --git a/src/api/server/subscription_socket_tests.rs b/src/api/server/subscription_socket_tests.rs index 5cfbc706..35b0bf12 100644 --- a/src/api/server/subscription_socket_tests.rs +++ b/src/api/server/subscription_socket_tests.rs @@ -224,6 +224,7 @@ fn reply_to_probe(request: ApiRequestMessage) { focused: true, cwd: None, foreground_cwd: None, + restore_error: None, label: None, agent: Some("pi".into()), title: None, diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 5be2d523..2258a3ce 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -701,6 +701,7 @@ mod tests { focused: true, cwd: None, foreground_cwd: None, + restore_error: None, label: None, agent: None, title: None, diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 2cbd93ab..9bf2157f 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -233,6 +233,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 +266,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; } }; @@ -378,6 +389,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() { 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/session.rs b/src/app/session.rs index 08c4cd44..509d4050 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -49,7 +49,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/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index 9f04b527..9d960143 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -183,6 +183,7 @@ fn pane_scroll_result( focused: true, cwd: None, foreground_cwd: None, + restore_error: None, label: None, agent: None, title: None, diff --git a/src/pane.rs b/src/pane.rs index 3e1c52a4..099580c3 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1255,6 +1255,8 @@ pub struct PaneRuntime { current_size: Cell<(u16, u16, u32, u32)>, child_pid: Arc<AtomicU32>, reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>, + persistence_cwd: Mutex<Option<std::path::PathBuf>>, + cwd_process_exited: Arc<AtomicBool>, child_wait_completed: Option<Arc<AtomicBool>>, kitty_keyboard_flags: Arc<AtomicU16>, content_seq: Arc<AtomicU64>, @@ -2304,6 +2306,7 @@ impl PaneRuntime { let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone()); let child_pid = Arc::new(AtomicU32::new(child_pid)); let reported_cwd = Arc::new(Mutex::new(None)); + let cwd_process_exited = Arc::new(AtomicBool::new(false)); let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags)); let content_seq = Arc::new(AtomicU64::new(0)); let content_write_lock = Arc::new(Mutex::new(())); @@ -2370,7 +2373,9 @@ impl PaneRuntime { } }); let exit_events = events.clone(); + let cwd_process_exited = cwd_process_exited.clone(); let on_reader_exit = Box::new(move || { + cwd_process_exited.store(true, Ordering::Release); // Imported handoff panes have no child wait handle, so their exit cause is // unknowable. Checkpoint conservatively; normal autosave settles clean exits. let _ = rt.block_on(exit_events.send(AppEvent::PaneDied { @@ -2405,6 +2410,8 @@ impl PaneRuntime { current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)), child_pid, reported_cwd, + persistence_cwd: Mutex::new(None), + cwd_process_exited, child_wait_completed: None, kitty_keyboard_flags, content_seq, @@ -2981,6 +2988,8 @@ impl PaneRuntime { current_size: Cell::new((rows, cols, 0, 0)), child_pid, reported_cwd, + persistence_cwd: Mutex::new(None), + cwd_process_exited: child_wait_completed.clone(), child_wait_completed: Some(child_wait_completed), kitty_keyboard_flags, content_seq, @@ -3491,6 +3500,27 @@ impl PaneRuntime { crate::platform::process_cwd(pid) } + pub fn cwd_for_persistence(&self) -> Option<std::path::PathBuf> { + let pid = self.child_pid.load(Ordering::Acquire); + let exited = self.cwd_process_exited.load(Ordering::Acquire); + if let Some(cwd) = (!exited) + .then(|| crate::platform::process_cwd(pid)) + .flatten() + .filter(|cwd| cwd.is_absolute()) + { + // Persistence observations must not change OSC authority or follow-cwd behavior. + if let Ok(mut known) = self.persistence_cwd.lock() { + *known = Some(cwd.clone()); + } + return Some(cwd); + } + self.persistence_cwd + .lock() + .ok() + .and_then(|cwd| cwd.clone()) + .or_else(|| self.reported_cwd.lock().ok().and_then(|cwd| cwd.clone())) + } + pub fn child_pid(&self) -> Option<u32> { let pid = self.child_pid.load(Ordering::Acquire); (pid > 0).then_some(pid) @@ -3661,6 +3691,8 @@ impl PaneRuntime { current_size: Cell::new((rows, cols, 0, 0)), child_pid: Arc::new(AtomicU32::new(0)), reported_cwd: Arc::new(Mutex::new(None)), + persistence_cwd: Mutex::new(None), + cwd_process_exited: Arc::new(AtomicBool::new(false)), child_wait_completed: None, kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), content_seq: Arc::new(AtomicU64::new(0)), @@ -4592,6 +4624,23 @@ mod tests { assert!(runtime.handoff_history_ansi().is_none()); } + #[cfg(unix)] + #[tokio::test] + async fn ended_handoff_keeps_persistence_cwd_when_pid_is_reused() { + let (runtime, _rx) = PaneRuntime::test_with_channel(80, 24); + assert!(runtime.child_wait_completed.is_none()); + let saved = std::env::temp_dir().join("saved-handoff-cwd"); + *runtime.persistence_cwd.lock().unwrap() = Some(saved.clone()); + // A different live process now owns the imported shell's numeric PID. + runtime + .child_pid + .store(std::process::id(), Ordering::Release); + runtime.cwd_process_exited.store(true, Ordering::Release); + assert_eq!(runtime.cwd_for_persistence(), Some(saved)); + *runtime.persistence_cwd.lock().unwrap() = None; + assert_eq!(runtime.cwd_for_persistence(), None); + } + #[cfg(unix)] #[tokio::test] async fn handoff_runtime_state_captures_terminal_input_and_title_state() { @@ -4749,6 +4798,8 @@ mod tests { )); let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone()); let runtime = PaneRuntime { + cwd_process_exited: Arc::new(AtomicBool::new(false)), + persistence_cwd: Mutex::new(None), pane_id, terminal, io: PaneRuntimeIo::TestChannel { @@ -4786,6 +4837,8 @@ mod tests { )); let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone()); let runtime = PaneRuntime { + cwd_process_exited: Arc::new(AtomicBool::new(false)), + persistence_cwd: Mutex::new(None), pane_id, terminal, io: PaneRuntimeIo::TestChannel { diff --git a/src/persist/io.rs b/src/persist/io.rs index 6f5ed459..b483b264 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -198,6 +198,7 @@ mod tests { fn history_snapshot(secret: &str) -> SessionHistorySnapshot { SessionHistorySnapshot { version: SNAPSHOT_VERSION, + layout_fingerprint: None, workspaces: vec![WorkspaceHistorySnapshot { tabs: vec![TabHistorySnapshot { panes: std::collections::HashMap::from([( diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 3c5775c9..31ee0a37 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -267,6 +267,14 @@ fn restore_with_imports_and_failures( render_notify: Arc<Notify>, render_dirty: Arc<RenderSignal>, ) -> RestoreFailures<RestoredSession> { + let history = history.filter(|history| { + let matches = history.layout_fingerprint.is_some() + && history.layout_fingerprint == super::snapshot::layout_fingerprint(snapshot); + if !matches { + tracing::warn!("Ignoring pane history without a matching session layout"); + } + matches + }); let mut workspaces = Vec::new(); let mut terminals = HashMap::new(); let mut terminal_runtimes = HashMap::new(); @@ -433,6 +441,34 @@ fn restore_workspace( ) } +fn unavailable_restored_terminal( + pane: Option<&super::snapshot::PaneSnapshot>, + cwd: PathBuf, + reason: String, +) -> TerminalState { + warn!(cwd = %cwd.display(), reason = %reason, "preserving unavailable restored pane"); + let mut terminal = TerminalState::new(TerminalId::alloc(), cwd); + terminal.restore_error = Some(reason); + if let Some(pane) = pane { + terminal.manual_label = pane.label.clone(); + terminal.launch_argv = pane.launch_argv.clone(); + if let Some(session) = restored_terminal_agent_session(pane.agent_session.as_ref(), false) { + terminal.set_persisted_agent_session(session); + } + match ( + pane.agent_name.as_ref(), + pane.managed_agent_kind + .as_deref() + .and_then(crate::detect::parse_canonical_agent_label), + ) { + (Some(name), Some(agent)) => terminal.restore_managed_agent(name.clone(), agent), + (Some(name), None) => terminal.set_agent_name(name.clone()), + _ => {} + } + } + terminal +} + fn restored_worktree_space_membership( space: Option<crate::workspace::WorktreeSpaceMembership>, ) -> Option<crate::workspace::WorktreeSpaceMembership> { @@ -473,22 +509,19 @@ fn restore_tab( .map(|p| p.cwd.clone()) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let cwd = if saved_cwd.exists() { - saved_cwd - } else { - warn!( - cwd = %saved_cwd.display(), - "saved pane cwd does not exist, falling back to HOME" + let cwd = saved_cwd; + let has_import = old_id.is_some_and(|old_id| imported_panes.contains_key(old_id)); + if !has_import && !cwd.is_dir() { + let terminal = unavailable_restored_terminal( + saved_pane, + cwd, + "Saved directory is unavailable. Restore the directory and restart this session." + .into(), ); - let home = std::env::var("HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/")); - if home.exists() { - home - } else { - PathBuf::from("/") - } - }; + panes.insert(*id, PaneState::new(terminal.id.clone())); + terminals.push(terminal); + continue; + } let saved_label = saved_pane.and_then(|p| p.label.clone()); let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone()); @@ -681,8 +714,16 @@ fn restore_tab( tab = ?snap.custom_name, pane_id = id.raw(), err = %e, - "failed to restore pane, skipping" + "failed to restore pane" ); + if !was_imported { + let terminal = unavailable_restored_terminal( + saved_pane, cwd, + format!("Could not start the saved shell: {e}. Fix the shell configuration and restart this session."), + ); + panes.insert(*id, PaneState::new(terminal.id.clone())); + terminals.push(terminal); + } } } } @@ -1167,6 +1208,92 @@ mod tests { assert!(restored_terminal_agent_session(Some(&session), true).is_none()); } + #[tokio::test] + async fn failed_cold_restore_preserves_panes_and_saved_directories() { + for missing_shell in [false, true] { + let mut snapshot: SessionSnapshot = serde_json::from_str(include_str!( + "../../tests/fixtures/session/current-herdr-session.json" + )) + .unwrap(); + let cwd = std::env::current_dir().unwrap(); + let missing = cwd.join("__herdr_missing_restore_directory__"); + assert!(!missing.exists()); + for workspace in &mut snapshot.workspaces { + workspace.identity_cwd = cwd.clone(); + for tab in &mut workspace.tabs { + for pane in tab.panes.values_mut() { + pane.cwd = cwd.clone(); + } + } + } + let failed = snapshot.workspaces[0].tabs[0].panes.get_mut(&1).unwrap(); + failed.cwd = missing.clone(); + failed.label = Some("keep my pane".into()); + failed.agent_session = Some(super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:opencode".into(), + agent: "opencode".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "keep-my-session".into(), + }); + let (events, _rx) = mpsc::channel(32); + let (workspaces, terminals, runtimes) = restore( + &snapshot, + None, + 24, + 80, + 0, + if missing_shell { + "__herdr_missing_restore_shell__" + } else { + test_restore_shell() + }, + crate::config::ShellModeConfig::NonLogin, + false, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ); + let runtimes = crate::terminal::TerminalRuntimeRegistry::from(runtimes); + let captured = crate::persist::capture(&workspaces, &terminals, &runtimes, Some(0), 0); + assert_eq!( + captured.workspaces.len(), + 2, + "a launch failure must not delete a workspace" + ); + assert_eq!(captured.workspaces[0].tabs.len(), 2); + let pane = captured.workspaces[0].tabs[0] + .panes + .values() + .next() + .unwrap(); + assert_eq!( + pane.cwd, missing, + "fallback cwd must not replace saved intent" + ); + assert_eq!(pane.label.as_deref(), Some("keep my pane")); + assert_eq!( + pane.agent_session.as_ref().unwrap().value, + "keep-my-session" + ); + let root = workspaces[0].tabs[0].root_pane; + let terminal_id = workspaces[0].tabs[0].terminal_id(root).unwrap(); + assert!( + runtimes.get(terminal_id).is_none(), + "do not open a replacement shell elsewhere" + ); + let healthy = workspaces[1].tabs[0] + .terminal_id(workspaces[1].tabs[0].root_pane) + .unwrap(); + assert_eq!(runtimes.get(healthy).is_some(), !missing_shell); + assert!(terminals[terminal_id].restore_error.is_some()); + let mut state = crate::app::AppState::test_new(); + state.workspaces = workspaces; + state.terminals = terminals; + state.active = Some(0); + state.assert_invariants_for_test(); + } + } + #[tokio::test] async fn restore_carries_persisted_agent_session_metadata() { let cwd = std::env::current_dir().unwrap(); @@ -1658,6 +1785,48 @@ mod tests { let _ = runtime.try_send_bytes(bytes::Bytes::from_static(b"exit\n")); } + #[tokio::test] + async fn restore_rejects_history_from_another_layout_or_without_provenance() { + for legacy in [false, true] { + let (mut snapshot, history) = snapshot_with_saved_pane_history(); + let mut value = serde_json::to_value(history).unwrap(); + if legacy { + value.as_object_mut().unwrap().remove("layout_fingerprint"); + } else { + snapshot.workspaces[0].tabs[0] + .panes + .get_mut(&0) + .unwrap() + .cwd = std::env::temp_dir(); + } + let history = serde_json::from_value(value).unwrap(); + let (events, _rx) = mpsc::channel(8); + let (_, _, runtimes) = restore( + &snapshot, + Some(&history), + 5, + 80, + 4096, + test_restore_shell(), + crate::config::ShellModeConfig::NonLogin, + false, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ); + let runtime = runtimes.values().next().unwrap(); + assert!( + !runtime + .recent_unwrapped_text(10) + .contains("RESTORED_HISTORY"), + "screen history must belong to the exact saved layout" + ); + for (_, runtime) in runtimes { + runtime.shutdown(); + } + } + } + fn snapshot_with_saved_pane_history() -> (SessionSnapshot, SessionHistorySnapshot) { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); let mut panes = HashMap::new(); @@ -1672,8 +1841,9 @@ mod tests { launch_argv: None, }, ); - let history = SessionHistorySnapshot { + let mut history = SessionHistorySnapshot { version: super::super::snapshot::SNAPSHOT_VERSION, + layout_fingerprint: None, workspaces: vec![WorkspaceHistorySnapshot { tabs: vec![super::super::snapshot::TabHistorySnapshot { panes: HashMap::from([( @@ -1717,6 +1887,7 @@ mod tests { sidebar_section_split: Some(0.5), collapsed_space_keys: Default::default(), }; + history.layout_fingerprint = super::super::snapshot::layout_fingerprint(&snapshot); (snapshot, history) } } diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 07aaf3b9..d7db3e97 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -33,6 +33,8 @@ pub struct SessionHistorySnapshot { /// Format version follows the matching session snapshot version. #[serde(default)] pub version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layout_fingerprint: Option<String>, pub workspaces: Vec<WorkspaceHistorySnapshot>, } @@ -281,12 +283,20 @@ fn capture_workspace( >, terminal_runtimes: &TerminalRuntimeRegistry, ) -> WorkspaceSnapshot { + let tabs: Vec<_> = ws + .tabs + .iter() + .map(|tab| capture_tab(tab, terminals, terminal_runtimes)) + .collect(); + let identity_cwd = tabs + .first() + .and_then(|tab| tab.root_pane.and_then(|id| tab.panes.get(&id))) + .map(|pane| pane.cwd.clone()) + .unwrap_or_else(|| ws.identity_cwd.clone()); WorkspaceSnapshot { id: Some(ws.id.clone()), custom_name: ws.custom_name.clone(), - identity_cwd: ws - .resolved_identity_cwd_from(terminals, terminal_runtimes) - .unwrap_or_else(|| ws.identity_cwd.clone()), + identity_cwd, worktree_space: ws.worktree_space.clone(), public_pane_numbers: ws .public_pane_numbers @@ -296,11 +306,7 @@ fn capture_workspace( next_public_pane_number: ws.next_public_pane_number, public_tab_numbers: ws.tabs.iter().map(|tab| tab.number).collect(), next_public_tab_number: ws.next_public_tab_number, - tabs: ws - .tabs - .iter() - .map(|tab| capture_tab(tab, terminals, terminal_runtimes)) - .collect(), + tabs, active_tab: ws.active_tab, } } @@ -315,13 +321,13 @@ fn capture_tab( ) -> TabSnapshot { let mut panes = HashMap::new(); for id in tab.panes.keys() { - let cwd = tab - .cwd_for_pane(*id, terminals, terminal_runtimes) + let terminal_id = tab.terminal_id(*id); + let terminal = terminal_id.and_then(|id| terminals.get(id)); + let cwd = terminal_id + .and_then(|id| terminal_runtimes.get(id)) + .and_then(|runtime| runtime.cwd_for_persistence()) + .or_else(|| terminal.map(|terminal| terminal.cwd.clone())) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let terminal = tab - .panes - .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)); let label = terminal.and_then(|terminal| terminal.manual_label.clone()); let (agent_name, managed_agent_kind) = terminal .filter(|terminal| !terminal.managed_agent_launch_pending()) @@ -378,13 +384,27 @@ fn capture_tab( } } +pub(super) fn layout_fingerprint(snapshot: &SessionSnapshot) -> Option<String> { + use sha2::{Digest, Sha256}; + + let mut value = serde_json::to_value(snapshot).ok()?; + // Sets serialize as arrays; normalize their order as well as JSON object keys. + let mut collapsed: Vec<_> = snapshot.collapsed_space_keys.iter().collect(); + collapsed.sort_unstable(); + value["collapsed_space_keys"] = serde_json::to_value(collapsed).ok()?; + let bytes = serde_json::to_vec(&value).ok()?; + Some(format!("{:x}", Sha256::digest(bytes))) +} + /// Capture pane screen history separately from the structural session snapshot. pub fn capture_history( + snapshot: &SessionSnapshot, workspaces: &[Workspace], terminal_runtimes: &TerminalRuntimeRegistry, ) -> SessionHistorySnapshot { SessionHistorySnapshot { version: SNAPSHOT_VERSION, + layout_fingerprint: layout_fingerprint(snapshot), workspaces: workspaces .iter() .map(|workspace| WorkspaceHistorySnapshot { @@ -546,7 +566,8 @@ mod tests { state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, ) -> SessionHistorySnapshot { - capture_history(&state.workspaces, terminal_runtimes) + let snapshot = capture_from_state_with_runtimes(state, terminal_runtimes); + capture_history(&snapshot, &state.workspaces, terminal_runtimes) } fn root_split_ratio(tab: &TabSnapshot) -> Option<f32> { @@ -593,6 +614,28 @@ mod tests { assert_eq!(active_pane.managed_agent_kind.as_deref(), Some("pi")); } + #[test] + fn layout_fingerprint_survives_json_round_trip() { + let mut snapshot = parse_snapshot(include_str!( + "../../tests/fixtures/session/current-herdr-session.json" + )) + .unwrap(); + snapshot.collapsed_space_keys = ["z", "a", "m"].map(String::from).into(); + let expected = layout_fingerprint(&snapshot).unwrap(); + for _ in 0..16 { + snapshot = parse_snapshot(&serde_json::to_string(&snapshot).unwrap()).unwrap(); + assert_eq!( + layout_fingerprint(&snapshot).as_deref(), + Some(expected.as_str()) + ); + } + snapshot.workspaces.swap(0, 1); + assert_ne!( + layout_fingerprint(&snapshot).as_deref(), + Some(expected.as_str()) + ); + } + #[test] fn round_trip_empty_session() { let snap = SessionSnapshot { @@ -1002,6 +1045,104 @@ mod tests { assert_eq!(workspace.next_public_tab_number, 3); } + #[cfg(unix)] + #[tokio::test] + async fn capture_prefers_live_shell_cwd_and_keeps_it_after_exit() { + let old = std::env::current_dir().unwrap(); + let new = std::env::temp_dir().join(format!( + "herdr-persist-cwd-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&new).unwrap(); + let new = std::fs::canonicalize(new).unwrap(); + let mut state = AppState::test_new(); + state.workspaces = vec![Workspace::test_new("cwd-source")]; + state.workspaces[0].identity_cwd = old.clone(); + state.active = Some(0); + state.ensure_test_terminals(); + let pane_id = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.workspaces[0].terminal_id(pane_id).unwrap().clone(); + let (events, _rx) = tokio::sync::mpsc::channel(32); + let runtime = crate::terminal::TerminalRuntime::spawn( + pane_id, + 24, + 80, + old.clone(), + 0, + Default::default(), + None, + crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), + &crate::pane::PaneLaunchEnv::default(), + events, + std::sync::Arc::new(tokio::sync::Notify::new()), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), + ) + .unwrap(); + let pid = runtime.child_pid().unwrap(); + runtime + .try_send_bytes(bytes::Bytes::from(format!( + "cd '{}'; printf '\\033]7;file://{}\\007'; exec sleep 30\n", + new.display(), + old.display() + ))) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while (crate::platform::process_cwd(pid).as_ref() != Some(&new) + || runtime.cwd().as_ref() != Some(&old)) + && std::time::Instant::now() < deadline + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_eq!(crate::platform::process_cwd(pid), Some(new.clone())); + assert_eq!( + runtime.cwd(), + Some(old.clone()), + "existing reported-cwd accessor is unchanged" + ); + let mut runtimes = TerminalRuntimeRegistry::new(); + runtimes.insert(terminal_id, runtime); + let before = capture_from_state_with_runtimes(&state, &runtimes); + assert_eq!( + before.workspaces[0].tabs[0] + .panes + .values() + .next() + .unwrap() + .cwd, + new + ); + assert_eq!(before.workspaces[0].identity_cwd, new); + assert_eq!(runtimes.values().next().unwrap().cwd(), Some(old.clone())); + crate::platform::signal_processes(&[pid], crate::platform::Signal::Kill); + let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while crate::platform::process_cwd(pid).is_some() + && std::time::Instant::now() < exit_deadline + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(crate::platform::process_cwd(pid).is_none()); + let after = capture_from_state_with_runtimes(&state, &runtimes); + assert_eq!( + after.workspaces[0].tabs[0] + .panes + .values() + .next() + .unwrap() + .cwd, + new + ); + assert_eq!(after.workspaces[0].identity_cwd, new); + assert_eq!(runtimes.values().next().unwrap().cwd(), Some(old)); + for (_, runtime) in runtimes.drain() { + runtime.shutdown(); + } + std::fs::remove_dir(new).unwrap(); + } + #[test] fn capture_contract_tracks_workspace_identity_and_pane_cwds() { let mut state = state_with_workspaces(&["one"]); @@ -1199,7 +1340,7 @@ mod tests { } #[test] - fn restore_falls_back_to_home_when_cwd_missing() { + fn snapshot_parsing_preserves_missing_cwd() { let mut panes = HashMap::new(); panes.insert( 0, diff --git a/src/persist/writer.rs b/src/persist/writer.rs index 48bbc496..be87a490 100644 --- a/src/persist/writer.rs +++ b/src/persist/writer.rs @@ -26,20 +26,31 @@ impl SessionWriter { Ok(()) } + fn preserve_snapshot_history(&self) { + if let Err(err) = preserve_snapshot_history(&self.path) { + tracing::warn!( + event = "persist.snapshot", outcome = "error", path = %self.path.display(), + err = %err, "failed to preserve session snapshot" + ); + } + } + pub(crate) fn save( &mut self, snapshot: &SessionSnapshot, history: Option<&SessionHistorySnapshot>, ) { - let result = self - .preserve_unloaded() - .and_then(|()| super::io::save_to_path(&self.path, snapshot)); + let result = self.preserve_unloaded().and_then(|()| { + self.preserve_snapshot_history(); + super::io::save_to_path(&self.path, snapshot) + }); if let Err(err) = result { crate::logging::session_save_failed(&self.path, &err.to_string()); return; } // Optional history failure must not reclassify our committed layout as unloaded. self.protect_unloaded = false; + self.preserve_snapshot_history(); let history_path = self.path.with_file_name("session-history.json"); if let Err(err) = super::io::save_history_to_path(&history_path, history) { crate::logging::session_save_failed(&history_path, &err.to_string()); @@ -48,9 +59,10 @@ impl SessionWriter { } pub(crate) fn clear(&mut self) { - let result = self - .preserve_unloaded() - .and_then(|()| super::io::clear_path(&self.path)); + let result = self.preserve_unloaded().and_then(|()| { + self.preserve_snapshot_history(); + super::io::clear_path(&self.path) + }); if let Err(err) = result { crate::logging::session_clear_failed(&self.path, &err.to_string()); return; @@ -63,7 +75,55 @@ impl SessionWriter { } } +const SNAPSHOT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(15 * 60); +const SNAPSHOT_LIMIT: usize = 48; + +fn preserve_snapshot_history(path: &Path) -> io::Result<()> { + let directory = path.with_file_name("session-snapshots"); + let existing = match recovery_files(&directory) { + Ok(files) => files, + Err(err) if err.kind() == io::ErrorKind::NotFound => Vec::new(), + Err(err) => return Err(err), + }; + if let Some((_, latest)) = existing.last() { + let modified = std::fs::metadata(latest)?.modified()?; + if SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age < SNAPSHOT_INTERVAL) + { + return Ok(()); + } + } + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + let Ok(snapshot) = serde_json::from_slice::<SessionSnapshot>(&bytes) else { + return Ok(()); + }; + if snapshot.version > super::snapshot::SNAPSHOT_VERSION || snapshot.workspaces.is_empty() { + return Ok(()); + } + if let Some((_, latest)) = existing.last() { + let previous_bytes = std::fs::read(latest)?; + if let Ok(previous) = serde_json::from_slice::<SessionSnapshot>(&previous_bytes) { + if super::snapshot::layout_fingerprint(&snapshot).is_some_and(|fingerprint| { + super::snapshot::layout_fingerprint(&previous).as_ref() == Some(&fingerprint) + }) { + return Ok(()); + } + } + } + preserve_existing_in(path, "session-snapshots", SNAPSHOT_LIMIT)?; + Ok(()) +} + fn preserve_existing(path: &Path) -> io::Result<bool> { + preserve_existing_in(path, "session-backups", 3) +} + +fn preserve_existing_in(path: &Path, directory_name: &str, keep: usize) -> io::Result<bool> { let mut source = match File::open(path) { Ok(file) => file, // Recheck on the next mutation until a fresh session is actually saved. @@ -73,7 +133,7 @@ fn preserve_existing(path: &Path) -> io::Result<bool> { if !source.metadata()?.is_file() { return Err(io::Error::other("session path is not a regular file")); } - let directory = path.with_file_name("session-backups"); + let directory = path.with_file_name(directory_name); std::fs::create_dir_all(&directory)?; let older = recovery_files(&directory)?; let now = SystemTime::now() @@ -105,9 +165,13 @@ fn preserve_existing(path: &Path) -> io::Result<bool> { outcome = "ok", path = %path.display(), backup_path = %backup.display(), - "preserved unloaded session before replacement" + "preserved session recovery copy" ); - if let Err(err) = prune_backups(&older) { + if let Err(err) = prune_backups(&older, keep) { + if directory_name == "session-snapshots" { + std::fs::remove_file(&backup)?; + return Err(err); + } tracing::warn!( event = "persist.backup", subsystem = "persist", outcome = "prune_error", path = %directory.display(), err = %err, "failed to prune session recovery copies" @@ -168,10 +232,23 @@ fn recovery_files(directory: &Path) -> io::Result<Vec<(u128, PathBuf)>> { Ok(files) } -fn prune_backups(older: &[(u128, PathBuf)]) -> io::Result<()> { +fn prune_backups(older: &[(u128, PathBuf)], keep: usize) -> io::Result<()> { // The new copy is durable before any of the previous copies are removed. - for (_, path) in older.iter().take(older.len().saturating_sub(2)) { - std::fs::remove_file(path)?; + let mut remaining = older.len().saturating_sub(keep.saturating_sub(1)); + let mut failure = None; + for (_, path) in older { + if remaining == 0 { + return Ok(()); + } + match std::fs::remove_file(path) { + Ok(()) => remaining -= 1, + Err(err) => failure = Some(err), + } + } + if remaining > 0 { + if let Some(err) = failure { + return Err(err); + } } Ok(()) } @@ -234,6 +311,144 @@ mod tests { .collect() } + fn snapshots(writer: &SessionWriter) -> Vec<(u128, PathBuf)> { + recovery_files(&writer.path.with_file_name("session-snapshots")).unwrap() + } + + #[test] + fn snapshot_survives_exit_bursts_clears_and_writer_restarts() { + let mut writer = writer(false); + let original = snapshot(); + writer.save(&original, None); + let files = snapshots(&writer); + assert_eq!(files.len(), 1); + let saved = std::fs::read(&files[0].1).unwrap(); + for i in 0..100 { + let mut shrinking = snapshot(); + shrinking.workspaces[0].custom_name = Some(format!("remaining pane {i}")); + writer.save(&shrinking, None); + writer = SessionWriter { + path: writer.path.clone(), + protect_unloaded: false, + }; + } + writer.clear(); + assert!( + !writer.path.exists(), + "intentional clear must still persist" + ); + assert_eq!(snapshots(&writer), files); + assert_eq!(std::fs::read(&files[0].1).unwrap(), saved); + assert!(backups(&writer).is_empty()); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn snapshot_history_is_bounded_and_does_not_rotate_identical_layouts() { + let mut writer = writer(false); + let directory = writer.path.with_file_name("session-snapshots"); + std::fs::create_dir(&directory).unwrap(); + let manual = directory.join("my-layout.json"); + std::fs::write(&manual, b"manual").unwrap(); + for i in 0..SNAPSHOT_LIMIT { + std::fs::write( + directory.join(format!("session-{i:039}-1-0.json")), + b"old snapshot", + ) + .unwrap(); + } + for (_, path) in snapshots(&writer) { + File::options() + .write(true) + .open(path) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(UNIX_EPOCH)) + .unwrap(); + } + writer.save(&snapshot(), None); + assert_eq!(snapshots(&writer).len(), SNAPSHOT_LIMIT); + assert!(!directory + .join(format!("session-{:039}-1-0.json", 0)) + .exists()); + assert!(manual.exists()); + + for (_, path) in snapshots(&writer) { + std::fs::remove_file(path).unwrap(); + } + let old = directory.join(format!("session-{:039}-1-0.json", 1)); + let saved = std::fs::read(&writer.path).unwrap(); + let reordered: serde_json::Value = serde_json::from_slice(&saved).unwrap(); + let equivalent = serde_json::to_vec(&reordered).unwrap(); + assert_ne!(saved, equivalent); + std::fs::write(&old, equivalent).unwrap(); + File::options() + .write(true) + .open(&old) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(UNIX_EPOCH)) + .unwrap(); + writer.save(&snapshot(), None); + assert_eq!(snapshots(&writer), vec![(1, old)]); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn snapshot_cadence_recovers_after_clock_rollback_and_restart() { + let mut writer = writer(false); + writer.save(&snapshot(), None); + let file = snapshots(&writer).pop().unwrap().1; + File::options() + .write(true) + .open(&file) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_modified(SystemTime::now() + std::time::Duration::from_secs(86400)), + ) + .unwrap(); + let mut changed = snapshot(); + changed.workspaces[0].custom_name = Some("after clock rollback".into()); + writer.save(&changed, None); + assert_eq!(snapshots(&writer).len(), 2); + writer = SessionWriter { + path: writer.path.clone(), + protect_unloaded: false, + }; + changed.workspaces[0].custom_name = Some("after restart".into()); + writer.save(&changed, None); + assert_eq!( + snapshots(&writer).len(), + 2, + "new mtime restores cadence across restart" + ); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn pruning_continues_past_an_undeletable_entry() { + let writer = writer(false); + let locked = writer.path.with_file_name("undeletable"); + std::fs::create_dir(&locked).unwrap(); + let removable = writer.path.with_file_name("removable"); + std::fs::write(&removable, b"old").unwrap(); + assert!(prune_backups(&[(1, locked.clone()), (2, removable.clone())], 2).is_ok()); + assert!(locked.exists()); + assert!(!removable.exists()); + assert!(prune_backups(&[(1, locked)], 1).is_err()); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn snapshot_failure_does_not_block_primary_save_and_clear() { + let mut writer = writer(false); + std::fs::write(writer.path.with_file_name("session-snapshots"), b"blocked").unwrap(); + writer.save(&snapshot(), None); + assert!(writer.path.exists()); + writer.clear(); + assert!(!writer.path.exists()); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + #[test] fn healthy_and_fresh_sessions_save_and_clear_without_backups() { for protect_unloaded in [false, true] { diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 7dd2879e..c73584e5 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -26,6 +26,9 @@ pub(crate) use super::unix_common::{ #[cfg(test)] mod config_file_tests; +mod shutdown; +pub(crate) use shutdown::monitor_host_shutdown; + const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"]; const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION"; const CHILD_GROUPS_SCAN_LIMIT: usize = 64; diff --git a/src/platform/linux/shutdown.rs b/src/platform/linux/shutdown.rs new file mode 100644 index 00000000..b678161a --- /dev/null +++ b/src/platform/linux/shutdown.rs @@ -0,0 +1,208 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::StreamExt; + +pub(crate) fn monitor_host_shutdown( + requested: Arc<AtomicBool>, + wake: impl Fn() + Send + Sync + 'static, +) -> Option<tokio::task::JoinHandle<()>> { + Some(tokio::spawn(async move { + let mut retry = Duration::from_secs(1); + loop { + match watch_shutdown(&requested, &wake).await { + Ok(()) => retry = Duration::from_secs(1), + Err(err) => { + tracing::debug!(err = %err, retry_seconds = retry.as_secs(), "host shutdown notification unavailable"); + tokio::time::sleep(retry).await; + retry = (retry * 2).min(Duration::from_secs(60)); + } + } + } + })) +} + +async fn watch_shutdown( + requested: &AtomicBool, + wake: &(impl Fn() + Send + Sync), +) -> zbus::Result<()> { + let connection = zbus::Connection::system().await?; + watch_connection(connection, requested, wake).await +} + +async fn watch_connection( + connection: zbus::Connection, + requested: &AtomicBool, + wake: &(impl Fn() + Send + Sync), +) -> zbus::Result<()> { + let manager = zbus::Proxy::new( + &connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .await?; + let mut owners = manager.receive_owner_changed().await?; + let mut signals = manager.receive_signal("PrepareForShutdown").await?; + let inhibitor: zbus::zvariant::OwnedFd = manager + .call( + "Inhibit", + &( + "shutdown", + "Herdr", + "Save terminal workspace layout", + "delay", + ), + ) + .await?; + tracing::debug!("host shutdown notification ready"); + + let mut preparing: bool = manager.get_property("PreparingForShutdown").await?; + loop { + if preparing { + tracing::info!("host shutdown requested; preserving session before pane termination"); + requested.store(true, Ordering::Release); + wake(); + // Keep the delay lock until the server has saved and drops its monitor. + // logind caps the delay even if the server gets stuck. + std::future::pending::<()>().await; + } + tokio::select! { + biased; + _ = owners.next() => break, + signal = signals.next() => { + let Some(signal) = signal else { break }; + preparing = signal.body().deserialize()?; + } + } + } + drop(inhibitor); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, Read}; + use std::os::unix::net::UnixStream; + use std::process::{Child, Command, Stdio}; + use std::sync::Mutex; + + struct PrivateBus(Child); + + impl Drop for PrivateBus { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + struct LoginManager { + preparing: bool, + peer: Arc<Mutex<Option<UnixStream>>>, + } + + #[zbus::interface(name = "org.freedesktop.login1.Manager")] + impl LoginManager { + fn inhibit( + &self, + what: &str, + _who: &str, + _why: &str, + mode: &str, + ) -> zbus::fdo::Result<zbus::zvariant::OwnedFd> { + assert_eq!((what, mode), ("shutdown", "delay")); + let (lock, peer) = UnixStream::pair().unwrap(); + peer.set_nonblocking(true).unwrap(); + *self.peer.lock().unwrap() = Some(peer); + Ok(std::os::fd::OwnedFd::from(lock).into()) + } + + #[zbus(property)] + fn preparing_for_shutdown(&self) -> bool { + self.preparing + } + } + + #[tokio::test] + #[ignore = "requires dbus-daemon; uses a private bus, never requests host shutdown"] + async fn shutdown_warning_holds_inhibitor_until_monitor_is_dropped() { + for already_preparing in [false, true] { + let mut bus = PrivateBus( + Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut address = String::new(); + std::io::BufReader::new(bus.0.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + let peer = Arc::new(Mutex::new(None)); + let service = zbus::connection::Builder::address(address.trim()) + .unwrap() + .name("org.freedesktop.login1") + .unwrap() + .serve_at( + "/org/freedesktop/login1", + LoginManager { + preparing: already_preparing, + peer: peer.clone(), + }, + ) + .unwrap() + .build() + .await + .unwrap(); + let client = zbus::connection::Builder::address(address.trim()) + .unwrap() + .build() + .await + .unwrap(); + let requested = Arc::new(AtomicBool::new(false)); + let wake = Arc::new(tokio::sync::Notify::new()); + let task = tokio::spawn({ + let requested = requested.clone(); + let wake = wake.clone(); + async move { + watch_connection(client, &requested, &move || wake.notify_one()) + .await + .unwrap(); + } + }); + tokio::time::timeout(Duration::from_secs(5), async { + while peer.lock().unwrap().is_none() { + tokio::task::yield_now().await; + } + if !already_preparing { + assert!(!requested.load(Ordering::Acquire)); + service + .emit_signal( + None::<&str>, + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + "PrepareForShutdown", + &true, + ) + .await + .unwrap(); + } + wake.notified().await; + }) + .await + .unwrap(); + assert!(requested.load(Ordering::Acquire)); + let mut peer = peer.lock().unwrap().take().unwrap(); + assert_eq!( + peer.read(&mut [0]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "shutdown must remain inhibited while the server saves" + ); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!(peer.read(&mut [0]).unwrap(), 0); + } + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 2ba3e6f3..488c5bee 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,36 @@ //! Centralizes OS-dependent behavior behind a clean boundary so core //! modules don't scatter `#[cfg]` branches through product logic. +pub(crate) struct HostShutdownMonitor { + task: Option<tokio::task::JoinHandle<()>>, +} + +impl HostShutdownMonitor { + pub(crate) fn start( + requested: std::sync::Arc<std::sync::atomic::AtomicBool>, + wake: impl Fn() + Send + Sync + 'static, + ) -> Self { + let task = monitor_host_shutdown(requested, wake); + Self { task } + } +} + +impl Drop for HostShutdownMonitor { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +#[cfg(not(target_os = "linux"))] +fn monitor_host_shutdown( + _requested: std::sync::Arc<std::sync::atomic::AtomicBool>, + _wake: impl Fn() + Send + Sync + 'static, +) -> Option<tokio::task::JoinHandle<()>> { + None +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ForegroundProcess { pub pid: u32, diff --git a/src/server/headless.rs b/src/server/headless.rs index f07c0c37..638559ad 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -250,6 +250,7 @@ pub struct HeadlessServer { pending_handoff_repaint_nudge: bool, /// Flag set by Ctrl+C or `server stop` signal. should_quit: Arc<AtomicBool>, + host_shutdown_requested: Arc<AtomicBool>, /// Channel for receiving server events from client connection threads. server_event_rx: mpsc::Receiver<ServerEvent>, /// Sender for server events (cloned for each client thread). @@ -373,6 +374,7 @@ impl HeadlessServer { headless_size, effective_size: headless_size, shutting_down: false, + host_shutdown_requested: Arc::new(AtomicBool::new(false)), handoff_in_progress: false, #[cfg(unix)] pending_handoff_repaint_nudge: false, @@ -398,6 +400,13 @@ impl HeadlessServer { let should_quit = self.should_quit.clone(); let quit_notify = self.server_event_tx.clone(); ctrlc_handler(should_quit, quit_notify); + let quit_notify = self.server_event_tx.clone(); + let _host_shutdown = crate::platform::HostShutdownMonitor::start( + self.host_shutdown_requested.clone(), + move || { + let _ = quit_notify.try_send(ServerEvent::QuitSignal); + }, + ); let mut needs_render = true; let mut needs_full_render = true; @@ -414,6 +423,13 @@ impl HeadlessServer { break; } + // A host shutdown warning precedes process termination. Do not drain pane + // deaths here: logind's delay lock stays held until the final session save. + if self.host_shutdown_requested.load(Ordering::Acquire) { + self.initiate_shutdown(); + continue; + } + // Check if we should start shutting down. if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) { self.drain_internal_events_with_forwarding_up_to( @@ -638,7 +654,9 @@ impl HeadlessServer { } }; - if self.should_quit.load(Ordering::Acquire) { + if self.should_quit.load(Ordering::Acquire) + || self.host_shutdown_requested.load(Ordering::Acquire) + { match event { LoopEvent::Internal(ev) => { self.handle_internal_event_with_forwarding(ev); diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index 39938b74..722f95c5 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -321,6 +321,9 @@ impl HeadlessServer { /// /// Returns true if the event changed visual state (requiring a re-render). pub(super) fn handle_internal_event_with_forwarding(&mut self, mut ev: AppEvent) -> bool { + if self.host_shutdown_requested.load(Ordering::Acquire) { + return false; + } let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev { result .api_request @@ -752,6 +755,9 @@ impl HeadlessServer { let mut had_event = false; let mut changed = false; for _ in 0..limit { + if self.host_shutdown_requested.load(Ordering::Acquire) { + break; + } let Ok(ev) = self.app.event_rx.try_recv() else { break; }; diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 60f89d63..cf6d389b 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -96,6 +96,7 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ headless_size, effective_size: headless_size, shutting_down: false, + host_shutdown_requested: Arc::new(AtomicBool::new(false)), handoff_in_progress: false, #[cfg(unix)] pending_handoff_repaint_nudge: false, @@ -4185,6 +4186,37 @@ fn changed_git_refresh_requests_headless_render() { assert!(changed); } +#[tokio::test] +async fn host_shutdown_preserves_panes_from_queued_and_selected_death_events() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("host-shutdown"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + let event = || AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::ChildExitReason::Exited, + }; + server.app.event_tx.try_send(event()).unwrap(); + server + .host_shutdown_requested + .store(true, Ordering::Release); + assert_eq!( + server.drain_internal_events_with_forwarding_up_to(16), + (false, false) + ); + assert!(!server.handle_internal_event_with_forwarding(event())); + assert!(server.app.find_pane(pane_id).is_some()); + assert!(server.app.event_rx.try_recv().is_ok()); + server + .host_shutdown_requested + .store(false, Ordering::Release); + assert!(server.handle_internal_event_with_forwarding(event())); + assert!(server.app.find_pane(pane_id).is_none()); + shutdown_test_runtimes(&mut server); +} + #[tokio::test] async fn pane_death_reconciles_each_client_view_and_focus() { let mut server = test_headless_server(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index a10bec78..7d5542a6 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -562,6 +562,10 @@ impl TerminalRuntime { self.0.cwd() } + pub fn cwd_for_persistence(&self) -> Option<std::path::PathBuf> { + self.0.cwd_for_persistence() + } + pub fn follow_cwd(&self) -> Option<std::path::PathBuf> { self.0.follow_cwd() } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 7ceaede7..c084b034 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -148,6 +148,7 @@ pub struct TerminalState { recent_agent_process_exit: Option<RecentAgentProcessExit>, agent_process_acquisition_pending: bool, pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>, + pub restore_error: Option<String>, } impl TerminalState { @@ -183,6 +184,7 @@ impl TerminalState { recent_agent_process_exit: None, agent_process_acquisition_pending: false, pending_agent_resume_plan: None, + restore_error: None, } } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index ab3e352a..be9df8da 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -2,7 +2,7 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - widgets::{Block, Borders}, + widgets::{Block, Borders, Paragraph, Wrap}, Frame, }; @@ -392,9 +392,10 @@ pub(super) fn render_panes( pane_infos: &[PaneInfo], split_borders: &[crate::layout::SplitBorder], ) { - let Some(ws_idx) = target.map(|target| target.workspace_index) else { + let Some(target) = target else { return; }; + let ws_idx = target.workspace_index; let Some(ws) = app.workspaces.get(ws_idx) else { return; }; @@ -406,6 +407,17 @@ pub(super) fn render_panes( && app.pane_exposes_host_cursor(ws_idx, info.id); rt.render(frame, info.inner_rect, show_cursor); render_pane_scrollbar(app, frame, info, rt); + } else if let Some(reason) = ws + .tabs + .get(target.tab_index) + .and_then(|tab| tab.terminal_id(info.id)) + .and_then(|id| app.terminals.get(id)) + .and_then(|terminal| terminal.restore_error.as_deref()) + { + frame.render_widget( + Paragraph::new(reason).wrap(Wrap { trim: false }), + info.inner_rect, + ); } } @@ -847,6 +859,32 @@ mod tests { render_pane_borders(app, ws, &app.view.pane_infos, split_borders, frame); } + #[test] + fn unavailable_pane_renders_restore_failure_without_a_runtime() { + let mut app = AppState::test_new(); + app.workspaces = vec![Workspace::test_new("unavailable")]; + app.active = Some(0); + app.ensure_test_terminals(); + let pane_id = app.workspaces[0].tabs[0].root_pane; + let terminal_id = app.workspaces[0].terminal_id(pane_id).unwrap().clone(); + app.terminals.get_mut(&terminal_id).unwrap().restore_error = + Some("Saved directory is unavailable. Restart to retry.".into()); + let (buffer, cursor, _, _) = crate::server::render_stream::render_tab_surface_virtual( + &app, + &TerminalRuntimeRegistry::new(), + Some(crate::ui::TabSurfaceTarget { + workspace_index: 0, + tab_index: 0, + }), + Rect::new(0, 0, 80, 24), + false, + Default::default(), + ); + let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); + assert!(text.contains("Saved directory is unavailable.")); + assert!(cursor.is_none_or(|cursor| !cursor.visible)); + } + #[test] fn pane_border_title_trims_and_truncates() { assert_eq!( diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 0b94b32e..9bcb4b2c 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -1796,10 +1796,7 @@ fn client_receives_pane_surface_after_pane_output() { } #[test] -fn pane_spawn_cwd_fallback_in_server() { - // Pane spawn failure cwd fallback in server context. - // This test verifies that the server can start even with invalid - // session data pointing to non-existent directories. +fn unavailable_restored_pane_keeps_saved_cwd_in_server() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1854,12 +1851,11 @@ fn pane_spawn_cwd_fallback_in_server() { assert_eq!(pane["result"]["pane"]["workspace_id"], workspace_id); let cwd = pane["result"]["pane"]["cwd"] .as_str() - .expect("restored pane should report fallback cwd"); - assert_ne!(cwd, missing_cwd); - assert!( - std::path::Path::new(cwd).exists(), - "fallback cwd should exist: {cwd}" - ); + .expect("restored pane should retain saved cwd"); + assert_eq!(cwd, missing_cwd); + assert!(pane["result"]["pane"]["restore_error"] + .as_str() + .is_some_and(|error| error.contains("directory"))); let client_shell = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); let output = spawn_pty_drain( @@ -1872,14 +1868,47 @@ fn pane_spawn_cwd_fallback_in_server() { ); assert!( wait_until(Duration::from_secs(8), Duration::from_millis(20), || { - read_output(&output).contains("missing-cwd") + let screen = read_output(&output); + screen.contains("missing-cwd") && screen.contains("unavailable") }), - "client shell should render the restored session; output: {:?}", + "client shell should render the unavailable pane; output: {:?}", read_output(&output) ); - + drop(client_shell); + let stopped = send_json_request( + &api_socket, + r#"{"id":"stop","method":"server.stop","params":{}}"#, + ); + assert!(stopped.get("error").is_none(), "{stopped}"); + let mut spawned = spawned; + assert!(wait_until( + Duration::from_secs(10), + Duration::from_millis(20), + || { spawned.child.try_wait().unwrap().is_some() } + )); drop(spawned); - cleanup_spawned_herdr(client_shell, base); + + fs::create_dir(missing_cwd).unwrap(); + let restarted = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + let recovered = send_json_request( + &api_socket, + &format!(r#"{{"id":"recovered","method":"pane.get","params":{{"pane_id":"{pane_id}"}}}}"#), + ); + assert_eq!( + std::fs::canonicalize(recovered["result"]["pane"]["cwd"].as_str().unwrap()).unwrap(), + std::fs::canonicalize(missing_cwd).unwrap() + ); + assert!(recovered["result"]["pane"]["restore_error"].is_null()); + let sent = send_json_request( + &api_socket, + &serde_json::json!({"id": "type", "method": "pane.send_text", "params": { + "pane_id": pane_id, "text": "printf 'RESTORE_RETRY_OK\\n'\n" + }}) + .to_string(), + ); + assert!(sent.get("error").is_none(), "{sent}"); + cleanup_spawned_herdr(restarted, base); } #[test] diff --git a/tests/host_shutdown.rs b/tests/host_shutdown.rs new file mode 100644 index 00000000..f988a52a --- /dev/null +++ b/tests/host_shutdown.rs @@ -0,0 +1,221 @@ +#![cfg(target_os = "linux")] + +use std::io::{BufRead, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +struct LoginManager { + peer: Arc<Mutex<Option<UnixStream>>>, +} + +#[zbus::interface(name = "org.freedesktop.login1.Manager")] +impl LoginManager { + fn inhibit( + &self, + what: &str, + _who: &str, + _why: &str, + mode: &str, + ) -> zbus::fdo::Result<zbus::zvariant::OwnedFd> { + assert_eq!((what, mode), ("shutdown", "delay")); + let (lock, peer) = UnixStream::pair().unwrap(); + peer.set_nonblocking(true).unwrap(); + *self.peer.lock().unwrap() = Some(peer); + Ok(std::os::fd::OwnedFd::from(lock).into()) + } + + #[zbus(property)] + fn preparing_for_shutdown(&self) -> bool { + false + } +} + +fn private_bus(address: &str) -> ChildGuard { + let mut child = ChildGuard( + Command::new("dbus-daemon") + .args([ + "--session", + "--nofork", + "--print-address=1", + "--address", + address, + ]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut ready = String::new(); + std::io::BufReader::new(child.0.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + child +} + +async fn login_service(address: &str, peer: Arc<Mutex<Option<UnixStream>>>) -> zbus::Connection { + zbus::connection::Builder::address(address) + .unwrap() + .name("org.freedesktop.login1") + .unwrap() + .serve_at("/org/freedesktop/login1", LoginManager { peer }) + .unwrap() + .build() + .await + .unwrap() +} + +async fn wait_for_inhibitor(peer: &Mutex<Option<UnixStream>>) { + tokio::time::timeout(Duration::from_secs(10), async { + while peer.lock().unwrap().is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); +} + +fn api(socket: &Path, method: &str, params: serde_json::Value) -> serde_json::Value { + let mut stream = UnixStream::connect(socket).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + writeln!( + stream, + "{}", + serde_json::json!({"id":"test", "method":method, "params":params}) + ) + .unwrap(); + let mut line = String::new(); + std::io::BufReader::new(stream) + .read_line(&mut line) + .unwrap(); + let response: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert!(response.get("error").is_none(), "{response}"); + response +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires dbus-daemon; exercises a private bus and disposable named server, not host shutdown"] +async fn host_shutdown_saves_layout_before_releasing_delay_lock() { + let base = std::path::PathBuf::from(format!( + "/var/tmp/hhs-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&base).unwrap(); + let address = format!("unix:path={}", base.join("bus").display()); + let mut bus = private_bus(&address); + let peer = Arc::new(Mutex::new(None)); + let mut service = login_service(&address, peer.clone()).await; + let socket = base.join("herdr-dev/sessions/shutdown/herdr.sock"); + let config = base.join("config.toml"); + std::fs::write(&config, "onboarding = false\n[experimental]\nallow_nested = true\n[terminal]\ndefault_shell = \"/bin/sh\"\n").unwrap(); + let mut server = ChildGuard( + Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["--session", "shutdown", "server"]) + .env("XDG_CONFIG_HOME", &base) + .env("XDG_STATE_HOME", &base) + .env("XDG_RUNTIME_DIR", &base) + .env("HERDR_CONFIG_PATH", &config) + .env_remove("HERDR_SOCKET_PATH") + .env("DBUS_SYSTEM_BUS_ADDRESS", address.trim()) + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_SESSION") + .env_remove("HERDR_WORKSPACE_ID") + .env_remove("HERDR_TAB_ID") + .env_remove("HERDR_PANE_ID") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + tokio::time::timeout(Duration::from_secs(10), async { + while !socket.exists() || peer.lock().unwrap().is_none() { + assert!( + server.0.try_wait().unwrap().is_none(), + "server exited during startup" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + // Neither a login1 owner change nor a bus restart may leave a stale inhibitor. + peer.lock().unwrap().take(); + service + .release_name("org.freedesktop.login1") + .await + .unwrap(); + drop(service); + service = login_service(&address, peer.clone()).await; + wait_for_inhibitor(&peer).await; + peer.lock().unwrap().take(); + drop(service); + drop(bus); + if base.join("bus").exists() { + std::fs::remove_file(base.join("bus")).unwrap(); + } + bus = private_bus(&address); + service = login_service(&address, peer.clone()).await; + wait_for_inhibitor(&peer).await; + + for label in ["one", "two", "three"] { + api( + &socket, + "workspace.create", + serde_json::json!({"cwd":base,"label":label,"focus":true}), + ); + } + service + .emit_signal( + None::<&str>, + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + "PrepareForShutdown", + &true, + ) + .await + .unwrap(); + let mut peer = peer.lock().unwrap().take().unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match peer.read(&mut [0]) { + Ok(0) => break, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {} + other => panic!("unexpected inhibitor state: {other:?}"), + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let saved = base.join("herdr-dev/sessions/shutdown/session.json"); + let layout: serde_json::Value = serde_json::from_slice(&std::fs::read(saved).unwrap()).unwrap(); + assert_eq!(layout["workspaces"].as_array().unwrap().len(), 3); + assert_eq!(layout["workspaces"][2]["custom_name"], "three"); + tokio::time::timeout(Duration::from_secs(5), async { + while server.0.try_wait().unwrap().is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + drop(server); + drop(service); + drop(bus); + std::fs::remove_dir_all(base).unwrap(); +} From 95e97bff3ac090ffc71bf24174ccc2449e97bcc6 Mon Sep 17 00:00:00 2001 From: kangal-bot <285672167+kangal-bot@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:51:02 +0000 Subject: [PATCH 35/99] docs: publish preview documentation --- distribution/preview.json | 86 ++++++++++--------- .../src/content/docs/cli-reference.mdx | 2 + .../src/content/docs/configuration.mdx | 2 +- .../src/content/docs/connecting-machines.mdx | 6 +- .../website/src/content/docs/integrations.mdx | 4 +- .../src/content/docs/ja/cli-reference.mdx | 12 ++- .../src/content/docs/ja/configuration.mdx | 4 +- .../content/docs/ja/connecting-machines.mdx | 14 ++- .../src/content/docs/ja/integrations.mdx | 2 + .../website/src/content/docs/ja/keyboard.mdx | 20 +++++ .../content/docs/ja/persistence-remote.mdx | 6 +- .../src/content/docs/ja/quick-start.mdx | 4 +- .../src/content/docs/ja/session-state.mdx | 26 +++++- .../src/content/docs/ja/socket-api.mdx | 14 ++- .../src/content/docs/ja/windows-beta.mdx | 7 +- .../website/src/content/docs/keyboard.mdx | 20 +++++ .../src/content/docs/session-state.mdx | 25 +++++- .../website/src/content/docs/socket-api.mdx | 48 +++++++++-- .../src/content/docs/zh-cn/cli-reference.mdx | 12 ++- .../docs/zh-cn/connecting-machines.mdx | 14 ++- .../src/content/docs/zh-cn/integrations.mdx | 2 + .../src/content/docs/zh-cn/keyboard.mdx | 20 +++++ .../content/docs/zh-cn/persistence-remote.mdx | 6 +- .../src/content/docs/zh-cn/quick-start.mdx | 4 +- .../src/content/docs/zh-cn/session-state.mdx | 26 +++++- .../src/content/docs/zh-cn/socket-api.mdx | 14 ++- .../src/content/docs/zh-cn/windows-beta.mdx | 7 +- .../website/src/data/config-reference.json | 8 +- 28 files changed, 322 insertions(+), 93 deletions(-) diff --git a/distribution/preview.json b/distribution/preview.json index 8a4e39b9..d802c093 100644 --- a/distribution/preview.json +++ b/distribution/preview.json @@ -1,37 +1,68 @@ { "schema_version": 1, "channel": "preview", - "base_version": "0.9.0", - "build_id": "2026-09-16-2c29fb29e302", - "commit": "2c29fb29e30220bc48d783af90ba8f53b81fd96c", - "built_at": "2026-09-16T16:22:39Z", + "base_version": "0.9.1", + "build_id": "2026-09-21-0ff0f27e2226", + "commit": "0ff0f27e222633c97ba4291f6b9be4137002ca84", + "built_at": "2026-09-20T21:32:22Z", "protocol": 22, "endpoint_generation": 1, - "notes": "Preview build 2026-09-16-2c29fb29e302\n\n[View changes](https://github.com/herdrdev/herdr/compare/62431dbd033bf1be2fe87fbc526df28084214e49...2c29fb29e30220bc48d783af90ba8f53b81fd96c)", + "notes": "Preview build 2026-09-21-0ff0f27e2226\n\n[View changes](https://github.com/herdrdev/herdr/compare/2c29fb29e30220bc48d783af90ba8f53b81fd96c...0ff0f27e222633c97ba4291f6b9be4137002ca84)", "assets": { "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-16-2c29fb29e302/herdr-linux-x86_64", - "sha256": "898c10e7004bafd96a5eb0cbdf417bd10958dae3b436d0249b9d70885653c30c" + "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-16-2c29fb29e302/herdr-linux-aarch64", - "sha256": "b378263788476bff3815f404695cd73251e236626d215d1e78adeb98641ff0ca" + "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-16-2c29fb29e302/herdr-macos-x86_64", - "sha256": "07f9c1c6be2bb95eb49778ff34bf81cce4a67c9de4cf228b3252901d9de97b45" + "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-16-2c29fb29e302/herdr-macos-aarch64", - "sha256": "398ab746369a8573a65fb42b1348df2bf31e22cd4f7d676fca8fca5fcb99a5a4" + "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-16-2c29fb29e302/herdr-windows-x86_64.zip", - "sha256": "32bb80b9da79b8ab2d8523a340cb50529ca21835128a2bf8ab5ece954f48b8de", + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-21-0ff0f27e2226/herdr-windows-x86_64.zip", + "sha256": "0bfd610f32becb5f299bb7bbcb48e4e6d07c1f57e41601ff3c0d7c99600b586d", "format": "zip" } }, "builds": { + "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", @@ -868,31 +899,6 @@ "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" - } - } } } } diff --git a/docs/preview/website/src/content/docs/cli-reference.mdx b/docs/preview/website/src/content/docs/cli-reference.mdx index 9f61d02d..cf9b355f 100644 --- a/docs/preview/website/src/content/docs/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/cli-reference.mdx @@ -197,6 +197,8 @@ herdr tab close <tab_id> A tab is another terminal layout inside a workspace. Without `--workspace`, `tab create` uses the active workspace and fails if none exists. Its JSON response exposes `.result.tab.tab_id` and `.result.root_pane.pane_id`. Closing a workspace's last tab also closes the workspace, matching the TUI close-tab action. If `confirm_close` is enabled and closing the tab would also close a whole worktree group, `tab close` returns a `confirmation_required` error instead. +With `ui.confirm_close` enabled, the TUI asks for confirmation before closing a workspace's last tab from a keybinding or the tab menu. Ordinary CLI/API tab closes remain immediate. + Workspace and tab creation, and pane splitting, leave focus unchanged by default. `--focus` selects the new layout; `--no-focus` states the default explicitly. Without `--cwd`, new terminals follow the configured `terminal.new_cwd` policy, which follows the source pane or workspace by default. Each `--env KEY=VALUE` adds or replaces that variable in the new root shell. ## Panes diff --git a/docs/preview/website/src/content/docs/configuration.mdx b/docs/preview/website/src/content/docs/configuration.mdx index 1975a43a..c54c79a9 100644 --- a/docs/preview/website/src/content/docs/configuration.mdx +++ b/docs/preview/website/src/content/docs/configuration.mdx @@ -73,7 +73,7 @@ Set the executable Herdr uses for newly created interactive panes: default_shell = "nu" ``` -When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix and PowerShell on Windows. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`. +When unset or empty, Herdr uses `$SHELL`, then `/bin/sh` on Unix. On Windows it uses `pwsh.exe` (PowerShell 7) when it resolves on `PATH`, and otherwise the inbox `powershell.exe` (Windows PowerShell 5.1). This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Custom command keybinding strings run through `/bin/sh -c` for pane commands and `/bin/sh -lc` for detached commands on Unix; on Windows they run through `cmd.exe /d /c`. Set how Herdr starts newly created interactive pane shells: diff --git a/docs/preview/website/src/content/docs/connecting-machines.mdx b/docs/preview/website/src/content/docs/connecting-machines.mdx index 3a31639d..3f7bcfe6 100644 --- a/docs/preview/website/src/content/docs/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/connecting-machines.mdx @@ -7,6 +7,8 @@ Keep your local work and remote agents in one Herdr window. Save an SSH machine Each machine keeps its own Herdr server, sessions, and running processes. A lost connection to one machine does not disconnect the others. +Herdr requests SSH compression to reduce screen-update traffic on limited connections. This works with older compatible Herdr servers and does not change how typing or other input is delivered. Herdr's `-C` option overrides `Compression no` in your SSH config. If SSH reuses an existing connection through an external ControlMaster, that connection keeps its original compression setting. + ## Before you connect You need normal SSH access to the remote machine. Verify it first: @@ -17,7 +19,7 @@ ssh workbox `workbox` can be a host from your SSH config. You can also use a target such as `ssh://you@server:2222`. -Multi-machine connections are supported on Linux and macOS clients, connecting to Linux and macOS servers on x86_64 or aarch64. Multi-machine connections are not yet verified or supported on Windows; standalone `herdr --remote` remains supported on Windows. Native Windows servers are not supported as SSH targets. See [Remote attach over SSH](/docs/persistence-remote/#remote-attach-over-ssh) for SSH configuration, authentication, and custom binaries. +Multi-machine connections support Linux, macOS, and Windows clients connecting to Linux or macOS servers on x86_64 or aarch64, or Windows servers on x86_64. Interactive setup can install or update the complete remote package after confirmation; background reconnects only discover installed packages. See [Remote attach over SSH](/docs/persistence-remote/#remote-attach-over-ssh) for SSH configuration, authentication, and custom binaries. ## Add a machine @@ -101,6 +103,8 @@ Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines Saved profiles contain only an opaque ID, label, SSH target, explicit remote session, and enabled state. Herdr does not store passwords, private keys, agent tickets, or SSH control sockets in the catalog. Authentication stays with OpenSSH. +Herdr separately remembers each machine's remote OS and resolved executable path so repeated `--machine` commands can skip discovery. Existing profiles learn missing information on first use. This cache is optional: missing, invalid, or unwritable cache files do not prevent commands from working. Commands still check live server compatibility. If a cached executable is missing or no longer supports API forwarding, Herdr rediscovers it during the initial read-only check; it never automatically repeats a command that may already have changed remote state. + The client and server negotiate compatibility rather than requiring identical versions. Saved-machine connections additionally need the server's `surface_interest` and `health_check` capabilities. Older servers without those capabilities show Attention until explicitly updated, even if a standalone attach works. Other missing server methods disable only their corresponding actions. Updating a compatible client does not replace the running remote server or stop its agents. When you need new server-side behavior, update that server explicitly. Normal replacement asks before stopping the server and its pane processes. diff --git a/docs/preview/website/src/content/docs/integrations.mdx b/docs/preview/website/src/content/docs/integrations.mdx index 4516061f..7bca01e6 100644 --- a/docs/preview/website/src/content/docs/integrations.mdx +++ b/docs/preview/website/src/content/docs/integrations.mdx @@ -223,7 +223,7 @@ herdr integration install opencode The integration supports OpenCode V1 `1.18.29` or later and OpenCode V2 (tested with beta `19242`). The OpenCode config directory must already exist. Herdr installs the server entrypoint at `~/.config/opencode/plugins/herdr-agent-state.js`, the shared TUI plugin at `herdr-tui-session.js`, and a V2 TUI entrypoint at `herdr-opencode/tui.js` in that config directory. -Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries. +Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. An existing V1 registration in `tui.json` also counts as installed and is reused without creating `tui.jsonc`. Uninstall removes the Herdr registration from both TUI config files while keeping the files and other preferences. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries. V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting. @@ -285,6 +285,8 @@ Herdr resumes stored Qwen Code sessions with `qwen --resume <id>`. ## Letta Code +Letta Code integration installation is experimental and CLI-only. It is not listed in the Settings integration screen or the JSON integration API. `herdr integration status` reports it as `letta (experimental)`. Agent detection and `herdr agent start --kind letta` are supported independently of this installation restriction. + Install the Letta Code hook: ```bash diff --git a/docs/preview/website/src/content/docs/ja/cli-reference.mdx b/docs/preview/website/src/content/docs/ja/cli-reference.mdx index 58cb3e9f..99a2e133 100644 --- a/docs/preview/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/ja/cli-reference.mdx @@ -191,7 +191,9 @@ herdr tab rename <tab_id> <label> herdr tab close <tab_id> ``` -タブはワークスペース内の別のターミナルレイアウトです。`--workspace` を省略すると、`tab create` はアクティブなワークスペースを使い、存在しなければ失敗します。JSON レスポンスの `.result.tab.tab_id` と `.result.root_pane.pane_id` から ID を取得できます。 +タブはワークスペース内の別のターミナルレイアウトです。`--workspace` を省略すると、`tab create` はアクティブなワークスペースを使い、存在しなければ失敗します。JSON レスポンスの `.result.tab.tab_id` と `.result.root_pane.pane_id` から ID を取得できます。ワークスペースの最後のタブを閉じると、そのワークスペースも閉じられます。`ui.confirm_close` が有効で、親ワークツリーの最後のタブを閉じるとワークツリーグループ全体が閉じられる場合、`tab close` は `confirmation_required` エラーを返します。 + +`ui.confirm_close` が有効な場合、キーバインドやタブメニューからワークスペースの最後のタブを閉じる前に、TUI が確認を求めます。通常の CLI/API によるタブの終了は引き続き即座に実行されます。 ワークスペースとタブの作成、およびペインの分割は、デフォルトではフォーカスを変えません。`--focus` は新しいレイアウトを選択し、`--no-focus` はデフォルトを明示します。`--cwd` がない場合、新しいターミナルは `terminal.new_cwd` の設定に従い、デフォルトでは元のペインまたはワークスペースを引き継ぎます。各 `--env KEY=VALUE` は新しいルートシェルの環境変数を追加または置換します。 @@ -218,7 +220,7 @@ herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--f herdr pane close <pane_id> ``` -`--current` を受け付けるペインコマンドは、Herdr ペイン内から実行した場合、呼び出し元の `HERDR_PANE_ID` を使います。`pane split` では、明示したペイン ID または `--pane ID` がそのペインを、`--current` が呼び出し元ペインを分割します。対象を省略すると UI でフォーカス中のペインを使います。分割レスポンスでは、新しいペイン ID は `.result.pane.pane_id` にあります。 +`--current` を受け付けるペインコマンドは、Herdr ペイン内から実行した場合、呼び出し元の `HERDR_PANE_ID` を使います。`pane split` では、明示したペイン ID または `--pane ID` がそのペインを分割します。対象を省略すると `HERDR_PANE_ID` があれば呼び出し元ペインを、なければフォーカス中のペインを使います。`--current` は呼び出し元ペインを必須とし、`HERDR_PANE_ID` がない場合はエラーになります。分割レスポンスでは、新しいペイン ID は `.result.pane.pane_id` にあります。 `pane move` の後続コマンドには `.result.move_result.pane.pane_id` を使います。別ワークスペースへの移動ではワークスペース修飾付きのペイン ID が変わり、以前の値は `.result.move_result.previous_pane_id` に残ります。実行中プロセスの `HERDR_PANE_ID`、`HERDR_TAB_ID`、`HERDR_WORKSPACE_ID` は起動時の値のままですが、Herdr は以前のペイン ID をそのターミナルの別名として保持するため、`--current` を使うペインコマンドは引き続き解決できます。ライブエージェント名もターミナルに追従します。 @@ -342,11 +344,17 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] ```bash herdr terminal attach <terminal_id> [--takeover] +herdr terminal session control <target> [--takeover] [--cols N] [--rows N] +herdr terminal session observe <target> [--cols N] [--rows N] herdr terminal title set <title> herdr terminal title clear ``` ダイレクトアタッチからは `ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 +`terminal session control` は、ペイン、ターミナル、エージェントを対象に書き込み可能なライブストリームを開きます。observe と同じ改行区切り JSON の `terminal.frame` と `terminal.closed` を出力し、標準入力から `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` を受け取ります。制御クライアントは同時に 1 つだけで、`--takeover` で置き換えられます。 + +`terminal session observe` は同じ対象の読み取り専用ストリームを開きます。base64 の ANSI バイト列を含む `terminal.frame` と、サーバー終了時の `terminal.closed` を改行区切り JSON で出力します。複数の観測クライアントは入力、リサイズ、スクロール、制御権を取得せずに同じターミナルを監視できます。Linux/macOS ではソケットへの書き込みが 30 秒間進まない観測クライアントを切断します。その場合は最後の `terminal.closed` が届かないことがありますが、ペインや他のクライアントは動き続けます。出力のない静かなペインはこのタイムアウトの対象ではありません。 + `terminal title clear` は外側ターミナルウィンドウタイトルを `ui.window_title` に戻します。 ## 出力待機 diff --git a/docs/preview/website/src/content/docs/ja/configuration.mdx b/docs/preview/website/src/content/docs/ja/configuration.mdx index 7867e8c4..12802e84 100644 --- a/docs/preview/website/src/content/docs/ja/configuration.mdx +++ b/docs/preview/website/src/content/docs/ja/configuration.mdx @@ -508,7 +508,9 @@ claude = "on" ## Kitty graphics -Herdr は、対応する外側のターミナルでペイン画像をデフォルトで描画します。グラフィックス描画とペイングラフィックス API を無効にするには、次のように設定します: +Herdr は、対応する外側のターミナルでペイン画像をデフォルトで描画します。ポップアップ、メニュー、通知は、重なる画像の配置だけを一時的に隠します。重ならない画像は表示されたままで、隠れた配置は覆いがなくなると戻ります。画像はダイアログの背景と一緒に暗くなりません。 + +グラフィックス描画とペイングラフィックス API を無効にするには、次のように設定します: ```toml [terminal] diff --git a/docs/preview/website/src/content/docs/ja/connecting-machines.mdx b/docs/preview/website/src/content/docs/ja/connecting-machines.mdx index 014e670f..2c3a41cc 100644 --- a/docs/preview/website/src/content/docs/ja/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/ja/connecting-machines.mdx @@ -17,7 +17,7 @@ ssh workbox `workbox` は SSH config のホスト名です。`ssh://you@server:2222` のような接続先も使えます。 -複数マシンへの接続は Linux と macOS のクライアントで対応しており、x86_64 または aarch64 の Linux と macOS サーバーに接続できます。Windows での複数マシンへの接続は未検証・未対応です。単独接続の `herdr --remote` は引き続き Windows でも対応しています。ネイティブ Windows サーバーは SSH 接続先として未対応です。SSH 設定、認証、カスタムバイナリについては [SSH 越しのリモートアタッチ](/ja/docs/persistence-remote/)を参照してください。 +複数マシンへの接続は Linux、macOS、Windows のクライアントに対応し、x86_64 または aarch64 の Linux と macOS サーバー、および x86_64 の Windows サーバーに接続できます。対話型セットアップでは確認後に完全なリモートパッケージをインストールまたは更新できます。バックグラウンド再接続ではインストール済みのパッケージだけを探します。SSH 設定、認証、カスタムバイナリについては [SSH 越しのリモートアタッチ](/ja/docs/persistence-remote/)を参照してください。 ## マシンを追加する @@ -43,7 +43,13 @@ Herdr はインストール済みバイナリと実行中サーバーをそれ サイドバーでマシンまたはそのワークスペースを選びます。選択中のマシンだけがペイン入力とターミナルサイズを受け取り、表示するターミナル内容とグラフィックスを送信します。ほかの接続済みマシンは、ペイン画面を送らずにワークスペース情報、エージェント状態、通知を更新し続けます。 -起動時は SSH 接続を待たずに Local を開きます。応答しないマシンがほかのマシンの入力を妨げることはありません。同じサーバーに接続した複数のクライアントは、それぞれ別のタブを表示できます。同じタブを表示した場合のサイズについては[クライアントとサーバー](/ja/docs/concepts/)を参照してください。 +各マシンの横の矢印をクリックすると、現在のワークスペースを切り替えずに一覧を折りたためます。再接続中のマシンでも操作できます。 + +キーボードでは `prefix+w` を押し、ワークスペース移動キー(デフォルトは Up/Down)で接続済みマシンのワークスペースをサイドバー順に選びます。Enter で移動を確定し、Esc またはプレフィックスキーで切り替えずにキャンセルします。縮小・展開サイドバーとも、折りたたまれたマシン内の選択行を表示します。デスクトップでは両端で折り返し、モバイルの切り替え画面では先頭と末尾で止まります。切断中のマシンは飛ばします。 + +別マシンのワークスペースをハイライトしたら、他のキー操作の前に Enter で切り替えてください。現在のマシンでペイン、タブ、ワークスペース、カスタムコマンドの操作が誤って実行されるのを防ぎます。クリックすると、デスクトップでのリモート移動のプレビューはキャンセルされます。展開サイドバーはマシンごとの worktree グループの折りたたみ状態を反映します。 + +起動時は SSH 接続を待たずに Local を開きます。Local を選ぶと、リモートの応答を待たずに未完了の切り替えをキャンセルします。Local 自体が再接続中なら、準備ができた時点で選択を再開します。Local の新しい画面とターミナル設定が届くと入力できます。応答しないマシンがほかのマシンの入力を妨げることはありません。同じサーバーに接続した複数のクライアントは、それぞれ別のタブを表示できます。同じタブを表示した場合のサイズについては[クライアントとサーバー](/ja/docs/concepts/)を参照してください。 接続が切れると、最後のワークスペースとエージェント状態を暗く表示します。これはキャッシュであり、現在の状態ではありません。新しい接続と対応する画面が届くまで、そのペインへの入力や移動は無効です。再接続しても、使用中のマシンから選択を奪いません。 @@ -67,10 +73,12 @@ herdr machine remove <profile-id> ## 接続の問題 -- **Reconnecting:** ネットワーク中断、スリープ、SSH 障害の後は、上限のある待機間隔で再試行します。SSH 接続はアプリケーションの通信を監視し、通信がない間はヘルスチェックを行うため、切れた接続が Online のままになりません。Local はリモート用のプローブではなく、ネイティブ接続の終了や失敗を検知します。 +- **Reconnecting:** ネットワーク中断、スリープ、SSH 障害の後は自動で再試行します。失敗が続くと待機間隔は最大 2 分まで延び、短時間だけ接続に成功してもリセットされません。1 分間安定して接続できると、次の中断では再び短い間隔で再試行します。SSH 接続はアプリケーションの通信を監視し、通信がない間はヘルスチェックを行うため、切れた接続が Online のままになりません。Local はリモート用のプローブではなく、ネイティブ接続の終了や失敗を検知します。 - **Attention:** ホスト鍵の承認、認証、互換サーバーの準備など、バックグラウンドではできない操作が必要です。ほかのマシンは利用できます。 - **保存済みマシンのファイルエラー:** カタログを読み取れない、または内容が不正な場合、現在の接続は変更しません。通知を表示して自動で読み取りを再試行します。 +両端のインストールがブリッジのアイドル終了に対応している場合、Linux と macOS への保存済みマシン接続は、どちらの方向にも通信がない状態が 1 分続くとリモートブリッジを終了します。スリープ時間も含み、復帰時に期限を確認します。正常なアイドル接続ではヘルスチェックを交換するため、継続的な出力を見るためにキーを入力する必要はありません。ブリッジ終了後もリモートサーバー、セッション、ペインのプロセスは動き続けます。古いインストールとは、この追加機能なしで接続できます。 + バックグラウンド接続がプロンプトに回答したり、サーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。Attention の場合は、Herdr が表示する単独接続用のコマンドを対話型ターミナルで実行してください。例: ```bash diff --git a/docs/preview/website/src/content/docs/ja/integrations.mdx b/docs/preview/website/src/content/docs/ja/integrations.mdx index 75cebbe4..6086e6b6 100644 --- a/docs/preview/website/src/content/docs/ja/integrations.mdx +++ b/docs/preview/website/src/content/docs/ja/integrations.mdx @@ -287,6 +287,8 @@ Herdr は保存された Qwen Code セッションを `qwen --resume <id>` で r ## Letta Code +Letta Code インテグレーションのインストールは実験的な CLI 専用機能です。Settings のインテグレーション一覧や JSON インテグレーション API には含まれません。`herdr integration status` では `letta (experimental)` と表示されます。エージェント検出と `herdr agent start --kind letta` は、このインストール方法の制限とは別にサポートされています。 + Letta Code フックをインストールします: ```bash diff --git a/docs/preview/website/src/content/docs/ja/keyboard.mdx b/docs/preview/website/src/content/docs/ja/keyboard.mdx index 97ce6b5c..ef0dfc2a 100644 --- a/docs/preview/website/src/content/docs/ja/keyboard.mdx +++ b/docs/preview/website/src/content/docs/ja/keyboard.mdx @@ -62,6 +62,26 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚 完全なキーマップとバインドの構文は[キーバインドリファレンス](/ja/docs/configuration/#キーバインド)にあります。 +Goto ピッカー (`prefix+g`) は、各エージェントとターミナルをワークスペースごとに一行ずつ表示します。分割タブのペインも隠さず表示されます。行をクリックするか Enter を押すと、そのペインへ直接移動します。`/` で名前、エージェントの種類、タブ、ワークスペース、ブランチ、パスを検索できます。検索入力中でなければ上下矢印キーまたは `j/k` で行を移動します。左右矢印キーは前後のワークスペースの最初のエージェントまたはターミナルへ移動し、端では折り返しません。検索入力中は左右矢印キーでテキストカーソルを動かします。`b/w/i/d` で blocked、working、idle、done のエージェントに絞り込み、`a` ですべてのターミナルに戻します。選択した移動先の場所とパスは一覧の下に表示されます。一覧が収まらない場合は、マウスホイール、スクロールバーのトラックのクリック、つまみのドラッグで、移動先を開かずにスクロールできます。 + +## テキスト欄の編集 + +Herdr の名前入力、worktree のブランチ名、フィルター、コピーモードの検索欄では、カーソル位置で編集できます。以下は Herdr 自身の入力欄の操作であり、ペイン内のシェルやエージェントの操作は変わりません。 + +| 操作 | キー | +| --- | --- | +| 1 文字移動 | Left / Right、Ctrl+B / Ctrl+F | +| 先頭 / 末尾へ移動 | Home / End、Ctrl+A / Ctrl+E | +| 1 単語移動 | Alt+B / Alt+F | +| 前の文字を削除 | Backspace、Ctrl+H | +| 次の文字を削除 | Delete、Ctrl+D | +| 先頭 / 末尾まで切り取り | Ctrl+U / Ctrl+K | +| 前の単語を切り取り | Ctrl+W、Alt+Backspace、Ctrl+Backspace | +| 次の単語を切り取り | Alt+D | +| 最後に切り取ったテキストを挿入 | Ctrl+Y | + +入力と貼り付けはカーソル位置に挿入されます。移動と削除は Unicode の書記素境界を尊重し、長い値は横にスクロールします。切り取ったテキストは現在の入力欄だけで保持し、システムのクリップボードは使いません。Alt ショートカットにはターミナルの Alt/Meta 報告が必要です。Enter と Esc は引き続きダイアログの確定とキャンセルに使います。 + ## コピーモード `prefix+[` を押すと、フォーカス中のペインでコピーモードに入ります。`h/j/k/l`、tmux 形式の `w/b/e`、`{`/`}` で移動します。`/` または `?` で前方または後方のリテラル検索を開始し、`n` または `N` で同じ方向または逆方向に繰り返します。クエリに大文字が含まれる場合だけ大文字と小文字を区別します。`v` または Space で選択を開始し、`y` または Enter でコピーし、`q` または Esc でコピーせずに抜けます。Esc は終了する前に、アクティブな選択または検索を消します。コピーモードはペインのプロセスを停止しません。最下部では出力を追従し、履歴へ移動するとその位置を保ちます。マウスのドラッグ選択なら、コピーモードに入らずにそのままコピーできます。 diff --git a/docs/preview/website/src/content/docs/ja/persistence-remote.mdx b/docs/preview/website/src/content/docs/ja/persistence-remote.mdx index acd75b86..94bffb8b 100644 --- a/docs/preview/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/ja/persistence-remote.mdx @@ -69,7 +69,7 @@ Host workbox herdr --remote workbox ``` -リモートアタッチは、Linux、macOS、Windows のローカルクライアントから、x86_64 と aarch64 の Linux および macOS ホストへの接続をサポートします。Herdr はリモートのプラットフォームを確認し、リモートの `PATH` 上にある互換性のある `herdr` を優先してから、一般的な直接インストール、Homebrew、mise、Nix プロファイルのパスを確認します。両方が安定したエンドポイント世代に対応していれば、ローカルとリモートのバージョンは一致しなくても接続できます。互換性のあるバイナリがない場合、対話的な実行では `~/.local/bin/herdr` へのインストールを提案します。非対話的な実行はホストを変更せずに失敗します。`~/.local/bin` がリモートの `PATH` にない場合、Herdr はインストール後に警告します。Windows をリモートホストとして使うことはできません。 +リモートアタッチと保存済み SSH マシンは、Linux、macOS、Windows のクライアントから、x86_64 または aarch64 の Linux/macOS ホスト、および x86_64 の Windows ホストへの接続に対応します。両端が安定したエンドポイント世代に対応していれば、バージョンは一致しなくても構いません。Linux/macOS ではリモートの `PATH` 上の互換バイナリを優先し、次に一般的な直接インストール、Homebrew、mise、Nix のパスを確認します。Windows では `PATH` と現在有効な管理対象リリースを確認します。互換バイナリがなければ、対話的な実行はインストールや更新の確認を求め、非対話的な実行はホストを変更せず失敗します。Linux/macOS では `~/.local/bin/herdr` に、Windows ではアプリローカル ConPTY ランタイムを含む完全なパッケージをインストールします。リモートの `PATH` に `~/.local/bin` がない場合は警告します。 デフォルトでは、`herdr --remote` はユーザーの SSH config を最初に include し、その後にフォールバックのキープアライブ設定を加えた一時的な SSH config を通してリモートセットアップとブリッジを実行します。既存のユーザーのキープアライブ設定が優先されます。Linux と macOS クライアントは接続再利用用のプライベートなアタッチ単位コントロールソケットも使いますが、Windows OpenSSH では使いません。Herdr が生成する設定やコントロールソケットを使わず通常の `ssh` を使うには `[remote].manage_ssh_config = false` を設定してください。 @@ -90,9 +90,9 @@ herdr --remote workbox --handoff 先にサーバーへ SSH してそこで `herdr` を実行した場合、Herdr は完全にサーバー上で動きます。このモードは便利でシンプルですが、通常のターミナルテキスト貼り付けを超えてローカルデスクトップのクリップボードにはアクセスできません。 -ローカルとリモートのプラットフォームが一致する場合、直接インストールでは Herdr は現在のローカルバイナリをコピーできます。Homebrew、mise、Nix のインストールの場合、またはプラットフォームが異なる場合は、`https://herdr.dev/latest.json` から現在のクライアントバージョンに一致するリリースアセットをダウンロードします。 +Linux/macOS ホストでは、プラットフォームが一致する直接インストールなら現在のローカルバイナリをコピーできます。Homebrew、mise、Nix のインストール、異なるプラットフォーム、または Windows ホストの場合は、`https://herdr.dev/latest.json` から現在のクライアントバージョンに対応するリリースアセットをダウンロードします。 -ローカルビルドやカスタムバイナリの場合は、リモートアタッチの前に `HERDR_REMOTE_BINARY` にローカルファイルパスを設定してください。 +ローカルビルドやカスタムパッケージを使うには、リモートアタッチ前に `HERDR_REMOTE_BINARY` にローカルファイルパスを設定します。Linux/macOS 向けには実行ファイル、Windows 向けには完全な `herdr-windows-x86_64.zip` パッケージを指定してください。 ```bash HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox diff --git a/docs/preview/website/src/content/docs/ja/quick-start.mdx b/docs/preview/website/src/content/docs/ja/quick-start.mdx index 4de15f61..e5e873ce 100644 --- a/docs/preview/website/src/content/docs/ja/quick-start.mdx +++ b/docs/preview/website/src/content/docs/ja/quick-start.mdx @@ -17,9 +17,9 @@ Herdr はデフォルトのバックグラウンドセッションを起動す ## マウスを使う -Herdr はマウスネイティブなので、まずはクリックから始めてください。ペイン、タブ、ワークスペース、エージェントをクリックしてフォーカスします。分割境界をドラッグしてリサイズします。右クリックでコンテキストメニューが開き、ペインの分割やタブの作成ができます。テキストをドラッグ選択するとクリップボードにコピーされます。トークンをダブルクリックすると直接コピーされます。コピーに Ctrl+C は不要です。 +Herdr はマウスネイティブなので、まずはクリックから始めてください。ペイン、タブ、ワークスペース、エージェントをクリックしてフォーカスします。分割境界をドラッグしてリサイズします。右クリックでコンテキストメニューが開き、ペインの分割やタブの作成ができます。テキストをドラッグ選択するとクリップボードにコピーされます。トークンをダブルクリックすると選択し、2 回目の押下を保持してドラッグすると単語単位で選択を広げられます。どちらもマウスを離すとコピーされます。コピーに Ctrl+C は不要です。 -ターミナルが修飾キー付きクリックを Herdr に渡す場合、Ctrl+クリックでペイン内のリンクを開けます。これは OSC 8 ハイパーリンクと、表示されている `http://` または `https://` の URL で機能します。macOS では、マウスキャプチャが有効な間は Herdr が処理するペインリンクに Ctrl+クリックを使ってください。Cmd+クリックは Shift+Cmd+クリックや `ui.mouse_capture = false` といったターミナルネイティブのバイパス経路でのみ使えます。 +ターミナルが修飾キー付きクリックを Herdr に渡す場合、Ctrl+クリックでペイン内のリンクを開けます。OSC 8 ハイパーリンクと `http://` または `https://` の URL に対応し、折り返されたリンクの先頭や末尾が表示範囲外でも開けます。Ctrl を押しながらリンク上にポインターを移動すると、表示中の部分に下線が付きます。ポインターを外す、ペイン内容が変わる、または Ctrl の解放イベントを受け取ると下線は消えます。一部のターミナルでは、修飾キーの変化はマウス移動時にだけ報告されます。macOS では、マウスキャプチャが有効な間は Herdr が処理するペインリンクに Ctrl+クリックを使ってください。Cmd+クリックは Shift+Cmd+クリックや `ui.mouse_capture = false` といったターミナルネイティブのバイパス経路でのみ使えます。 `ui.right_click_passthrough_modifier` を設定すると、その修飾キー + 右クリックで、右クリック・ホールド・ドラッグのジェスチャーがマウスレポーティング対応のペインアプリに送られます。 diff --git a/docs/preview/website/src/content/docs/ja/session-state.mdx b/docs/preview/website/src/content/docs/ja/session-state.mdx index b2d182b0..4f16bda6 100644 --- a/docs/preview/website/src/content/docs/ja/session-state.mdx +++ b/docs/preview/website/src/content/docs/ja/session-state.mdx @@ -34,10 +34,29 @@ Herdr サーバーが停止して再起動すると、元のペインのプロ スナップショット復元は、動作中のシェル、サーバー、テスト、その他任意のプロセスを保存しません。より強力な復元経路が使えないペインは、保存されたディレクトリで新しいシェルとして戻ってきます。 -`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。正常に読み込めた場合は復旧用コピーを作成しません。 +保存されたディレクトリが使えない場合やシェルを起動できない場合、ペインは消えたりホームディレクトリへ移動したりせず、エラーを表示してレイアウトに残ります。保存されたディレクトリとエージェントのセッション参照は保持されます。ディレクトリやシェル設定を修正してサーバーを再起動すると再試行できます。不要なペインは明示的に閉じて削除できます。 + +保存時には、OS が取得をサポートしていれば、実行中のシェルのディレクトリを優先します。シェル終了後も最後に確認したディレクトリを保持します。それ以外の環境ではシェルが報告したディレクトリを使用します。 + +`session.json` を読み込めない、解析できない、または新しい Herdr バージョンが必要な場合、読み込みに失敗した理由をログに記録します。新しいセッションを保存または削除する前に、元のファイルをそのまま `session.json` の隣の `session-backups/` に保存し、復旧用コピーのパスを `persist.backup` として記録します。起動時にファイルがなかった場合も、最初の保存または削除の前に再確認します。これらの読み込み失敗時の復旧用コピーは、通常のスナップショット履歴とは別に管理します。 復旧用コピーは最新の 3 個を保持し、新しいコピーの安全な保存が完了してから古いコピーを削除します。コピーに失敗すると、自動保存時も終了時も元のファイルを変更せず、失敗をログに記録し、次の保存要求時に再試行します。コピーが自動的に復元されることはありません。復旧するには、対象のサーバーを停止し、復旧用ファイルをそのサーバーの `session.json` にコピーしてから再起動します。復旧用コピーにペイン画面履歴は含まれません。 +## シャットダウンとスナップショットからの復旧 + +systemd-logind を使う Linux では、Herdr はホストのシャットダウン予告を受け取り、保存して停止するまで短い遅延を要求します。その後 logind がシャットダウンを進めます。遅延には OS による上限があり、無期限にはブロックしません。logind がない環境、強制終了、電源断、予告前にプロセスが終了した場合には、この保護は使えません。 + +全プラットフォームで、`session.json` の隣の `session-snapshots/` に最大 48 個のレイアウトスナップショットを保持します。最初に保存したレイアウトは直ちにコピーします。その後の保存や削除では、直前の保存済みレイアウトを最大 15 分に 1 回コピーします。同じ内容は重複保存しません。この間隔はサーバーの再起動後も維持されるため、連続したペイン終了や再起動で古いコピーがすべて押し出されることはありません。直近の変更がコピーに含まれない場合があります。 + +通常のペイン終了は引き続き `session.json` に反映されます。復旧を促す通知や、古いスナップショットの自動復元はありません。手動で復旧するには: + +1. `herdr session list --json` で対象のセッションディレクトリを確認します。 +2. 対象サーバーを停止します。デフォルトは `herdr server stop`、名前付きセッションは `herdr session stop <name>` です。 +3. 現在の `session.json` を別にコピーしてから、`session-snapshots/` 内の選んだファイルを `session.json` にコピーします。ファイルの更新日時で保存時刻を確認できます。 +4. セッションを再起動します。 + +コピーにはレイアウトとエージェントのセッション参照が含まれますが、実行中のプロセスやペイン画面履歴は含まれません。非公開のセッションデータとして扱ってください。コピーの書き込みに失敗するとログに記録しますが、通常のセッション保存は続行します。 + ## ペイン画面履歴のリプレイ ペイン画面履歴は、サーバーの完全な再起動後に直近のターミナル内容を復元します。復元されるのは Herdr が表示できるものであって、元のプロセスではありません。 @@ -51,6 +70,8 @@ pane_history = true 有効にすると、Herdr は保存したペイン履歴を `session.json` の隣の `session-history.json` に保存します。Herdr の設定/セッションディレクトリはターミナル履歴と同じ感覚で扱ってください。 +履歴は、保存されたレイアウトと完全に一致する場合だけ再生されます。スナップショットを手動で復元した場合など、異なるレイアウトの履歴は無視されます。レイアウトを検証できない旧形式の履歴も無視されますが、新たに保存された履歴は次回の再起動で再生できます。 + ## エージェントネイティブのセッション復元 一部のエージェントは自分の会話セッションを resume できます。Herdr は、公式インテグレーションが報告したセッション参照を使って、Herdr サーバーの再起動後に対応エージェントのペインを再起動できます。 @@ -75,6 +96,7 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ | Claude Code | `6` | `claude --resume <id>` | | Codex | `5` | `codex resume <id>` | | Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | +| Grok CLI | `2` | `grok --resume <id>` | | GitHub Copilot CLI | `2` | `copilot --resume=<id>` | | Devin CLI | `2` | `devin --resume <id>` | | Droid | `2` | `droid --resume <id>` | @@ -101,6 +123,8 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ ハンドオフが保護するのは、ペインの PTY とプロセス、エージェントの識別情報と永続メタデータ、交換後のサーバーに必要なプラグイン/セッション状態など、サーバーが所有する長寿命のセッション状態です。交換境界をまたぐ一時的な協調状態は保持しません。処理中の CLI/API リクエスト、wait、購読ストリーム、クライアントソケット、ペイン間メッセージは中断される可能性があるため、クライアントは再接続して再試行してください。 +更新済みの Unix サーバーは、ペインを分割して転送することで 64 個を超えるペインを 1 回のハンドオフで送信できます。送信側サーバーがすでにこの機能を備えている必要があります。古いサーバーからの最初の更新では、引き続き 64 ペインの制限が適用されます。その場合は通常のサーバー再起動が必要になることがあり、ペインのプロセスは終了します。分割転送によって更新のインストール順序や他の互換性要件が変わるわけではありません。 + ライブハンドオフは実験的機能で、オプトインです: ```bash diff --git a/docs/preview/website/src/content/docs/ja/socket-api.mdx b/docs/preview/website/src/content/docs/ja/socket-api.mdx index c57b3ace..09532d5e 100644 --- a/docs/preview/website/src/content/docs/ja/socket-api.mdx +++ b/docs/preview/website/src/content/docs/ja/socket-api.mdx @@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50 `workspace.move_block` は、順序付きの `workspace_ids` を `before_workspace_id` の前へアトミックに移動します。アンカーを省略するとブロックを末尾へ移動します。id は一意である必要があり、アンカーをブロックに含めることはできません。レスポンスにはサーバーが確定した順序付きワークスペース一覧が含まれます。 -`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。ブートストラップ中のイベントを取りこぼさないよう、まず別の接続で `events.subscribe` を開いて確認応答を待ち、そのストリームをバッファしながら `session.snapshot` を呼び出します。スナップショットを適用してから、バッファしたイベントを順番に適用し、そのままストリーミングを続けます。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。 +`session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。キャッシュの初期化と復旧では、先に購読を開始し、後述のイベント購読セクションの手順でサーバーから現在の状態を読み取り、キャッシュを照合・更新します。イベントはキャッシュの無効化を知らせるものであり、スナップショットに無条件で再適用できるものではありません。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。 CLI の `herdr api snapshot` は、クライアントやエージェントが簡単にブートストラップできるよう、現在の `session.snapshot` レスポンスを JSON で出力します。 @@ -348,7 +348,7 @@ worktree コマンドはライフサイクルイベントも発行します。`w フィールドとして `{"token":"name"}` を使います。値には文字列、真偽値、符号なし整数、 またはコンテキストオブジェクトを指定できます。コンテキスト値は `current_workspace_id` と `current_tab_id` で、対応する ID フィールドとの比較にだけ -使えます。有効な status は `idle`、`working`、`blocked`、`done`、`unknown` です。 +使えます。保存済みマシンに接続するクライアントでは、選択中サーバーのビューを統合エージェント一覧に適用します。現在のワークスペースとタブのコンテキストには選択中のマシンも含まれるため、別マシンの同じ ID は一致しません。一方、`status` が `blocked` かどうかなど独立したフィルター条件は、接続中のすべてのマシンに一致できます。有効な status は `idle`、`working`、`blocked`、`done`、`unknown` です。 `done` は idle かつ未確認の状態を表します。 ソートフィールドは `workspace_order`、`tab_order`、`pane_order`、`attention`、 @@ -639,10 +639,16 @@ workspace の get/list 応答は結果の `tokens` マップを公開し、ス 最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。ライフサイクルイベントの購読はリクエストが受理された時点から始まり、それ以前に保持されていたイベントは再生しません。 +ライフサイクルとエージェント状態のイベントは、各購読内の順序を保った上限付きのバッチで送信されます。イベント履歴は永続ログではありません。購読の初期化中も含め、購読者が保持範囲より遅れた場合、サーバーは元のリクエスト `id` と `error.code: "events_lost"` を含むエラーレスポンスを送り、その購読接続を閉じます。欠落したイベントを通知せずに配信を続けることはありません。他のクライアント接続には影響しません。履歴はイベント種別間で共有されるため、失われたイベントが購読条件に一致しなかった可能性があっても、保持範囲を超えたことを通知します。 + +`events_lost` を受け取ったら、キャッシュを古い状態として扱います。新しい購読を開始して `subscription_started` を待ち、読み取りを続けながら別の接続で `session.snapshot` を取得してください。サーバーの現在の状態でキャッシュを照合し、スナップショットでキャッシュ全体を置き換えます。イベントはキャッシュの無効化通知として扱い、再取得の契機にします。取得は直列に行い、取得中にイベントが届いた場合は完了後に再取得してください。 + +スナップショットとイベントに共通のシーケンス境界はありません。順序が保証されるのは各購読項目内のみで、項目間やスナップショットとの前後関係は保証されません。バッファしたイベントの内容をスナップショットへ無条件に再適用しないでください。復旧できるのは現在の状態であり、失われたイベント履歴ではありません。再び `events_lost` を受け取った場合は、再購読して状態を照合し直します。読み取りが遅いクライアントでは、読み取り速度を上げるかイベントの負荷を減らす必要があります。 + ワークスペースのイベント購読には `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed`、`workspace.focused` があります。`workspace.metadata_updated` はプラグインイベントフックを実行せずにトークン変更と TTL 失効を報告します。その他のワークスペースイベントは Herdr の UI/ランタイムのライフサイクルを記述します。ワークスペースが worktree グループに属している場合、`workspace.created` は任意の `workspace.worktree` 出自情報を含みます。`workspace.moved` は、移動した `workspace_id`、要求された `insert_index`、更新済みの順序付き `workspaces` リストを含みます。`workspace.reordered` は、アトミックに移動した `workspace_ids`、省略可能な `before_workspace_id`、サーバーが確定した順序付き `workspaces` リストを含みます。削除前に Herdr がまだ識別できる場合、`workspace.closed` は最終的な `workspace` スナップショットを含みます。 タブのイベント購読には `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed`、`tab.moved` があります。`tab.moved` は、移動した `tab_id`、`workspace_id`、要求された `insert_index`、そのワークスペースの更新済みの順序付き `tabs` リストを含みます。 -ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。 -レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でブートストラップするクライアントは、同じ `workspace_id` と `tab_id` のキャッシュ済みレイアウトを置き換えてください。 +ペインのイベント購読には `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed`、`pane.scroll_changed` があります。`pane.focused` は、このサーバーに接続した任意のクライアントでの手動ペイン選択も報告します。他のクライアントの表示は移動させず、ペイロードにクライアントの識別情報は含みません。選択済みのペインを再度選んでも発行しません。ターミナルタイトルの変更は `pane.updated` を発行することがありますが、生のタイトルでスピナーだけが変化し、`terminal_title_stripped` が変わらない場合は発行しません。`pane.scroll_changed` は 1 つの `pane_id` を対象とし、Herdr がスクロールスナップショットの変化を検出するたびに `pane_id`、`workspace_id`、現在の `scroll` 情報を発行します。 +レイアウトのイベント購読には `layout.updated` があります。このイベントは 1 つのタブについて更新済みの `PaneLayoutSnapshot` を運びます。`session.snapshot` でキャッシュを管理するクライアントは、このイベントを契機にサーバーから対象のレイアウトを再取得してください。遅れて届いたペイロードは、最新のスナップショットより古い可能性があります。 worktree のイベント購読には `worktree.created`、`worktree.opened`、`worktree.removed` があります。worktree イベントは Git チェックアウトのライフサイクルを記述します。`worktree.created` は開かれた `workspace` と作成された `worktree` を含みます。`worktree.opened` は対象の `workspace`、開かれた `worktree`、`already_open` を含みます。`worktree.removed` は `workspace_id`、削除された `worktree`、`forced` を含みます。 diff --git a/docs/preview/website/src/content/docs/ja/windows-beta.mdx b/docs/preview/website/src/content/docs/ja/windows-beta.mdx index 7d51fe0d..db708133 100644 --- a/docs/preview/website/src/content/docs/ja/windows-beta.mdx +++ b/docs/preview/website/src/content/docs/ja/windows-beta.mdx @@ -32,7 +32,7 @@ Windows ビルドは安定版とプレビューの両方のアップデートチ | ローカル永続セッション | サポート済み | | ConPTY によるネイティブペイン | サポート済み | | Windows Terminal / PowerShell アプリからのアタッチ | サポート済み | -| Linux/macOS ホストへの `herdr --remote` | サポート済み | +| Linux/macOS/Windows ホストへの `herdr --remote` と保存済み SSH マシン | サポート済み。対話的なアタッチとマシン追加では確認後に Windows パッケージをインストール・更新できます。バックグラウンド再接続ではインストール済みパッケージだけを探します | | リモートクリップボード画像と画像ファイルのドロップ | サポート済み | | `cmd.exe` ペイン | サポート済み | | ネイティブのキーボード/マウス入力 | サポート済み | @@ -80,7 +80,7 @@ switch_ascii_input_source_in_prefix = true その他の Windows IME はまだこのオプションに対応していません。[設定](/ja/docs/configuration/#プレフィックス入力ソースの切り替え)を参照してください。 -Windows 上の一部のエージェントは `ctrl+v` を受け取り、クリップボード画像を直接読み取れます。Herdr 自身のクリップボード画像リーダーはローカルのネイティブ Windows ペインには配線されていないため、エージェント自身による画像貼り付けはターミナルとエージェントに依存します。`alt+v` などエージェントの画像貼り付けショートカットによって、Herdr が管理するローカルクリップボードブリッジが追加されるわけではありません。リモートクリップボード画像ブリッジは、Linux/macOS ホストへの `herdr --remote` で別途サポートされます。 +Windows 上の一部のエージェントは `ctrl+v` を受け取り、クリップボード画像を直接読み取れます。Herdr 自身のクリップボード画像リーダーはローカルのネイティブ Windows ペインには配線されていないため、エージェント自身による画像貼り付けはターミナルとエージェントに依存します。`alt+v` などエージェントの画像貼り付けショートカットによって、Herdr が管理するローカルクリップボードブリッジが追加されるわけではありません。リモートクリップボード画像ブリッジは、Linux/macOS/Windows ホストへの `herdr --remote` で別途サポートされます。 Kitty graphics はデフォルトで有効で、外側のターミナルに依存します。Herdr は他のプラットフォームと同様に Windows でも Kitty graphics protocol の出力を送ります。この経路は Windows の WezTerm から WSL 内の Herdr をホストする構成で動作確認されていますが、ネイティブ Windows のターミナルと ConPTY の組み合わせはすべて検証済みではありません。Windows Terminal は Herdr が使う Kitty graphics 経路を提供しません。外側のターミナルがグラフィックス出力を正しく扱えない場合は、`[terminal].kitty_graphics = false` に設定してください。 @@ -118,7 +118,6 @@ Herdr のペインテキストコピーは Windows で動作します。ペイ | 機能 | 状況 | | --- | --- | | ダイレクトターミナルアタッチ (`herdr terminal attach`) | 未サポート | -| `herdr --remote` の接続先としての Windows | 未サポート | | ライブサーバーハンドオフ | 未サポート | | Unix ファイルディスクリプタのハンドオフ | 未サポート | | Unix フォアグラウンドプロセスグループ | 未サポート | @@ -131,7 +130,7 @@ Windows Terminal からは、Linux や macOS と同じリモートコマンド herdr --remote workbox ``` -接続先ホストは Linux または macOS である必要があります。Herdr はインストール済みの Windows OpenSSH クライアントと SSH 設定を使います。Windows OpenSSH では Herdr の Unix コントロールソケットによる接続再利用を使わないため、セットアップ中の繰り返しプロンプトを避けるには Windows の `ssh-agent` による鍵認証を推奨します。 +接続先ホストは Linux、macOS、Windows に対応します。Windows ホストでは `PATH` または現在有効な管理対象リリースから互換パッケージを再利用します。対話的なアタッチと保存済みマシンのセットアップは、必要に応じて完全なパッケージをインストール・更新する前に確認します。バックグラウンド再接続ではインストール済みパッケージだけを探し、インストールや更新の確認は行いません。Herdr はインストール済みの Windows OpenSSH クライアントと SSH 設定を使います。Windows OpenSSH では Herdr の Unix コントロールソケットによる接続再利用を使わないため、セットアップ中の繰り返しプロンプトを避けるには Windows の `ssh-agent` による鍵認証を推奨します。 Windows のアップデートは Windows インストーラー経由で行われ、有効なバージョン付きリリースパスを更新します。新しいターミナルまたは再接続した SSH セッションはそのパスを受け取るため、そこで Herdr を起動すると更新済みクライアントを使えます。互換性のある実行中サーバーはペインを動かしたままにします。新しいリリースのサーバー側変更が必要になったときだけ、後からサーバーを再起動してください。ライブハンドオフは Unix 専用です。 diff --git a/docs/preview/website/src/content/docs/keyboard.mdx b/docs/preview/website/src/content/docs/keyboard.mdx index d20b34b3..9e3a7053 100644 --- a/docs/preview/website/src/content/docs/keyboard.mdx +++ b/docs/preview/website/src/content/docs/keyboard.mdx @@ -62,6 +62,26 @@ Workspaces and session: The full keymap and the binding syntax live in the [keybinding reference](/docs/configuration/#keybindings). +The Goto picker (`prefix+g`) lists each agent or terminal on its own row, grouped by workspace. Split tabs never hide their panes behind another row. Click a row or press Enter to jump directly to it. Press `/` to search names, agent kinds, tabs, workspaces, branches, or paths; use Up/Down or `j/k` to move between rows when not typing a search. Left/Right jumps to the first agent or terminal in the previous/next workspace section, without wrapping. While editing a search, Left/Right moves the text cursor instead. Outside search, `b/w/i/d` filter blocked, working, idle, or done agents, and `a` shows all terminals again. The selected destination's location and path appear below the list. When the list overflows, use the mouse wheel, click the scrollbar track, or drag its thumb to scroll without opening a destination. + +## Editing text fields + +Herdr's naming dialogs, worktree branch fields, filters, and copy-mode search support editing at the cursor. These shortcuts apply to Herdr-owned fields, not to the shell or agent inside a pane. + +| Action | Keys | +| --- | --- | +| Move one character | Left / Right, Ctrl+B / Ctrl+F | +| Move to the beginning / end | Home / End, Ctrl+A / Ctrl+E | +| Move one word | Alt+B / Alt+F | +| Delete the previous character | Backspace, Ctrl+H | +| Delete the next character | Delete, Ctrl+D | +| Cut to the beginning / end | Ctrl+U / Ctrl+K | +| Cut the previous word | Ctrl+W, Alt+Backspace, Ctrl+Backspace | +| Cut the next word | Alt+D | +| Insert the last cut text | Ctrl+Y | + +Typing and paste insert at the cursor. Movement and deletion respect Unicode grapheme boundaries, and long values scroll horizontally. Cut text stays within the current field; it does not use the system clipboard. Alt shortcuts require the terminal to report Alt/Meta. Enter and Esc retain the dialog's confirm and cancel behavior. + ## Copy mode Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, tmux-style `w/b/e` and big-word `W/B/E`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move. Press `/` or `?` for forward or backward literal search, then `n` or `N` to repeat in the same or opposite direction. Search is case-insensitive unless the query contains an uppercase letter. Use `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. Esc clears an active selection or search before exiting. Copy mode does not pause the pane process: output remains live, follows at the bottom, and stays pinned when you navigate into history. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. diff --git a/docs/preview/website/src/content/docs/session-state.mdx b/docs/preview/website/src/content/docs/session-state.mdx index 38712689..b8706cd5 100644 --- a/docs/preview/website/src/content/docs/session-state.mdx +++ b/docs/preview/website/src/content/docs/session-state.mdx @@ -32,10 +32,29 @@ If the Herdr server stops and starts again, the original pane processes are gone Snapshot restore does not preserve running shells, servers, tests, or arbitrary processes. Panes that cannot use a stronger restore path come back as new shells in their saved directories. -If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. Healthy restores do not create recovery copies. +If a saved directory is unavailable or the shell cannot start, the pane stays in the layout with an error instead of disappearing or moving to your home directory. Its saved directory and agent session reference remain intact. Fix the directory or shell configuration and restart the server to retry, or explicitly close the pane to remove it. + +When saving, Herdr prefers the live shell's directory where the operating system exposes it, and retains the last confirmed directory after the shell exits. Shell-reported directories remain the fallback on other platforms. + +If `session.json` cannot be read or parsed, or requires a newer Herdr version, Herdr logs why loading failed. Before saving or clearing the replacement session, it preserves the original bytes in `session-backups/` beside `session.json` and logs the recovery path as `persist.backup`. A file missing at startup is checked again before the first save or clear. These failed-load recovery copies are separate from normal snapshot history. Herdr retains the three most recent recovery copies, pruning older copies only after a new copy is safely written. If preservation fails, autosave and shutdown leave the original untouched and log the failure; preservation is retried on the next save request. Copies are never restored automatically. To recover one, stop the affected server, copy the recovery file over its `session.json`, then restart. Recovery copies do not include pane screen history. +## Shutdown and snapshot recovery + +On Linux systems with systemd-logind, Herdr listens for the host shutdown warning and requests a short delay while it saves and stops. This happens before logind proceeds with shutdown. The operating system limits the delay; Herdr does not block shutdown indefinitely. This protection is unavailable without logind and does not cover forced shutdown, power loss, or processes killed before the warning arrives. + +Herdr also keeps up to 48 layout snapshots in `session-snapshots/` beside `session.json`, on all platforms. The first saved layout is copied immediately. Later saves or clears can add a snapshot of the previous saved layout, at most once every 15 minutes. Unchanged snapshots are not duplicated. The interval survives server restarts, so a burst of pane exits or restarts cannot rotate away all older layouts. Recent changes may not be in the recovery snapshots. + +Normal pane closures still update `session.json`. There are no recovery prompts and no automatic restoration of older snapshots. To recover a layout: + +1. Find the affected session directory with `herdr session list --json`. +2. Stop that server (`herdr server stop` for the default session, or `herdr session stop <name>` for a named session). +3. Keep a copy of its current `session.json`, then copy the chosen file from `session-snapshots/` over `session.json`. File modification times identify when snapshots were taken. +4. Start the session again. + +Snapshots contain layout and agent session references, not running processes or pane screen history. Treat them as private session data. A snapshot-write failure is logged but does not prevent the primary session from saving. + ## Pane screen history replay Pane screen history restores recent terminal contents after a full server restart without restoring the old process. @@ -49,6 +68,8 @@ pane_history = true When enabled, Herdr stores saved pane history in `session-history.json` next to `session.json`. Treat the Herdr config/session directory like terminal history. +History is replayed only when it matches the exact saved layout. History from a different layout, including after manual snapshot recovery, is ignored. Older history files without layout verification are also ignored; newly saved history can be replayed on the next restart. + ## Native agent session restore Some agents can resume their own conversation sessions. Herdr can use official integration-reported session references to restart supported agent panes after a Herdr server restart. @@ -101,6 +122,8 @@ Unlike snapshot restore, pane history replay, and native agent session restore, A successful handoff preserves long-lived server-owned session state: pane PTYs and processes, agent identity and durable metadata, and plugin/session state needed by the replacement server. It does not preserve transient coordination across the replacement boundary. In-flight CLI or API requests, waits, subscription streams, client sockets, and pane-to-pane messages may be interrupted; clients should reconnect and retry them. +Updated Unix servers can send more than 64 panes in one handoff by transferring them in batches. The sending server must already contain this support: an older running server still enforces its 64-pane limit on the first upgrade. That limit may require a normal server restart, which ends pane processes. Batching does not change update installation order or remove other handoff compatibility requirements. + Live handoff is experimental and opt-in: ```bash diff --git a/docs/preview/website/src/content/docs/socket-api.mdx b/docs/preview/website/src/content/docs/socket-api.mdx index 5bd933dd..e6ae6a57 100644 --- a/docs/preview/website/src/content/docs/socket-api.mdx +++ b/docs/preview/website/src/content/docs/socket-api.mdx @@ -118,12 +118,11 @@ Raw socket method names use dot notation: `session.snapshot` returns a one-time bootstrap snapshot for clients that keep their own local runtime cache. The response includes version/protocol metadata, focused workspace/tab/pane ids, workspace records, tab records, pane records, -tab layout snapshots, and agent records. It is not a subscription. To avoid a -bootstrap gap, first open `events.subscribe` on another connection and wait for -its acknowledgement. Buffer that stream while calling `session.snapshot`, -install the snapshot, then apply the buffered events in order and continue -streaming. Call `session.snapshot` again after reconnecting or when the local -cache may be stale. Attached worktree provenance is included on workspace +tab layout snapshots, and agent records. It is not a subscription. For cache +bootstrap and recovery, subscribe first, then reconcile through authoritative +reads as described under Event subscriptions below. Events invalidate cached state; they +cannot be unconditionally replayed over a snapshot. Call `session.snapshot` +again after reconnecting or when the local cache may be stale. Attached worktree provenance is included on workspace records. Full repo worktree discovery remains `worktree.list`. From the CLI, `herdr api snapshot` prints the live `session.snapshot` response @@ -670,6 +669,11 @@ A successful response includes the same `id`: {"id":"req_1","result":{"type":"pong"}} ``` +For request lines that reach JSON decoding, error responses also echo the request's +`id`, including when the method or parameters are invalid. If the JSON is invalid +or has no unambiguous top-level string `id`, the error response uses an empty `id`. +Transport errors, such as invalid UTF-8, can close the connection without a response. + Event subscriptions keep the connection open after the initial response. ## Socket paths @@ -814,9 +818,35 @@ Subscribe to events when you need a long-lived stream: ``` The first response acknowledges the subscription. Later lines are pushed events. +All entries must be valid: if any referenced pane does not exist, Herdr rejects +the entire request with an error and closes the connection. Refresh the pane list +and retry rather than assuming the other entries were subscribed. Lifecycle subscriptions start when the request is accepted and do not replay events retained before that point. +Lifecycle and agent-status events are drained in bounded batches, in order within +each subscription. Event history is not durable. If a subscriber falls behind +the retained history, including while its subscriptions are being initialized, +the server sends an error response with the original request `id` and +`error.code: "events_lost"`, then closes that subscription connection instead of +silently continuing with missing events. Other clients remain connected. +The history is shared across event types, so an overrun is reported even when +the evicted events might not have matched this subscription's filters. + +On `events_lost`, treat the cached state as stale. Open a new subscription, +wait for `subscription_started`, and keep reading while requesting +`session.snapshot` on another connection. Use authoritative reads to reconcile +current state: replace the cache with the snapshot, and treat incoming events +as invalidation signals that trigger another read. Serialize refreshes and +refresh again if events arrive while a read is in flight. + +Snapshots and events have no shared sequence boundary. Ordering is guaranteed +within each subscription entry, not across entries or relative to a snapshot, +so do not unconditionally replay buffered event payloads onto the snapshot. +Recovery restores current state, not the missing event history. Another +`events_lost` requires resubscribing and reconciling again. Slow readers may +need to increase read throughput or reduce event load. + Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.metadata_updated`, `workspace.renamed`, `workspace.moved`, `workspace.reordered`, `workspace.closed`, and `workspace.focused`. `workspace.metadata_updated` reports token changes and TTL expiry without invoking plugin event hooks. Other workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.moved` includes the moved `workspace_id`, requested `insert_index`, and updated ordered `workspaces` list. `workspace.reordered` includes the atomically moved `workspace_ids`, optional `before_workspace_id`, and authoritative ordered `workspaces` list. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal. Tab event subscriptions include `tab.created`, `tab.closed`, `tab.focused`, `tab.renamed`, and `tab.moved`. `tab.moved` includes the moved `tab_id`, @@ -833,9 +863,9 @@ Terminal-title changes can emit `pane.updated`, but spinner-only raw-title chang `workspace_id`, and the current `scroll` metrics whenever Herdr observes a changed scroll snapshot. Layout event subscriptions include `layout.updated`. The event carries the -updated `PaneLayoutSnapshot` for one tab. Clients that bootstrap with -`session.snapshot` should replace the cached layout with the same -`workspace_id` and `tab_id`. +updated `PaneLayoutSnapshot` for one tab. Clients maintaining a cache with +`session.snapshot` should use this event to refresh the affected layout through +an authoritative read; a delayed payload may predate their latest snapshot. Worktree event subscriptions include `worktree.created`, `worktree.opened`, and `worktree.removed`. Worktree events describe Git checkout lifecycle. `worktree.created` includes the opened `workspace` and created `worktree`. `worktree.opened` includes the target `workspace`, opened `worktree`, and `already_open`. `worktree.removed` includes the `workspace_id`, removed `worktree`, and `forced`. diff --git a/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx index 8795c45e..011efbd3 100644 --- a/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx @@ -191,7 +191,9 @@ herdr tab rename <tab_id> <label> herdr tab close <tab_id> ``` -标签页是工作区内的另一个终端布局。不指定 `--workspace` 时,`tab create` 使用活动工作区;如果没有活动工作区则失败。JSON 响应中的 `.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。 +标签页是工作区内的另一个终端布局。不指定 `--workspace` 时,`tab create` 使用活动工作区;如果没有活动工作区则失败。JSON 响应中的 `.result.tab.tab_id` 和 `.result.root_pane.pane_id` 是对应 ID。关闭工作区的最后一个标签页也会关闭该工作区。启用 `ui.confirm_close` 时,如果关闭父工作树的最后一个标签页会连带关闭整个工作树组,`tab close` 会返回 `confirmation_required` 错误。 + +启用 `ui.confirm_close` 时,通过快捷键或标签页菜单关闭工作区的最后一个标签页前,TUI 会要求确认。普通的 CLI/API 标签页关闭操作仍立即执行。 创建工作区、标签页和拆分窗格默认不改变焦点。`--focus` 选择新布局;`--no-focus` 显式表达默认行为。不指定 `--cwd` 时,新终端遵循 `terminal.new_cwd` 设置,默认跟随来源窗格或工作区。每个 `--env KEY=VALUE` 在新的根 shell 中添加或替换一个环境变量。 @@ -218,7 +220,7 @@ herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--f herdr pane close <pane_id> ``` -接受 `--current` 的窗格命令在 Herdr 窗格内运行时使用调用方的 `HERDR_PANE_ID`。对 `pane split` 而言,显式窗格 ID 或 `--pane ID` 拆分该窗格,`--current` 拆分调用方窗格,省略目标则使用 UI 当前聚焦的窗格。拆分响应中的新窗格 ID 位于 `.result.pane.pane_id`。 +接受 `--current` 的窗格命令在 Herdr 窗格内运行时使用调用方的 `HERDR_PANE_ID`。对 `pane split` 而言,显式窗格 ID 或 `--pane ID` 拆分指定窗格。省略目标时,若存在 `HERDR_PANE_ID` 则拆分调用方窗格,否则使用当前聚焦的窗格。`--current` 必须有调用方窗格;缺少 `HERDR_PANE_ID` 时会报错。拆分响应中的新窗格 ID 位于 `.result.pane.pane_id`。 执行 `pane move` 后,后续命令应使用 `.result.move_result.pane.pane_id`。跨工作区移动会改变带工作区前缀的窗格 ID;旧值保留在 `.result.move_result.previous_pane_id`。运行中进程的 `HERDR_PANE_ID`、`HERDR_TAB_ID` 和 `HERDR_WORKSPACE_ID` 保持启动时的值,但 Herdr 会把旧窗格 ID 保留为该终端的别名,所以使用 `--current` 的窗格命令仍能解析它。实时智能体名称也会跟随终端。 @@ -342,11 +344,17 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] ```bash herdr terminal attach <terminal_id> [--takeover] +herdr terminal session control <target> [--takeover] [--cols N] [--rows N] +herdr terminal session observe <target> [--cols N] [--rows N] herdr terminal title set <title> herdr terminal title clear ``` 从直接附加中用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 +`terminal session control` 为窗格、终端或智能体打开可写的实时终端流,输出与 observe 相同的逐行 JSON `terminal.frame` 和 `terminal.closed`,并从标准输入读取 `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` 命令。同一终端同时只能有一个控制方,可用 `--takeover` 接管。 + +`terminal session observe` 为相同目标打开只读实时流,输出包含 base64 ANSI 字节的逐行 JSON `terminal.frame`,服务器关闭流时输出 `terminal.closed`。多个观察方可以同时查看终端,不获取输入、调整尺寸、滚动或接管权限。Linux/macOS 上,若套接字写入连续 30 秒没有进展,观察方会被断开;此时可能没有最后的 `terminal.closed` 记录,但窗格和其他客户端继续运行。安静、没有输出的窗格不会触发此超时。 + `terminal title clear` 把外层终端窗口标题交还给 `ui.window_title`。 ## 输出等待 diff --git a/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx b/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx index 8180452b..4175d5ee 100644 --- a/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx @@ -17,7 +17,7 @@ ssh workbox `workbox` 可以是 SSH 配置中的主机名,也可以使用 `ssh://you@server:2222` 这样的目标。 -多机器连接支持 Linux 和 macOS 客户端,连接目标为 x86_64 或 aarch64 的 Linux 和 macOS 服务器。Windows 上的多机器连接尚未验证,也暂不受支持;独立连接命令 `herdr --remote` 仍支持 Windows。原生 Windows 服务器不能作为 SSH 目标。有关 SSH 配置、认证和自定义二进制文件,请参阅[通过 SSH 远程连接](/zh-cn/docs/persistence-remote/)。 +多机器连接支持 Linux、macOS 和 Windows 客户端,连接目标可以是 x86_64 或 aarch64 的 Linux/macOS 服务器,也可以是 x86_64 的 Windows 服务器。交互式设置可在确认后安装或更新完整的远程软件包;后台重连只查找已安装的软件包。有关 SSH 配置、认证和自定义二进制文件,请参阅[通过 SSH 远程连接](/zh-cn/docs/persistence-remote/)。 ## 添加机器 @@ -43,7 +43,13 @@ Herdr 分别检查已安装的二进制文件和运行中的服务器,并在 在侧边栏选择机器或其工作区。只有选中的机器接收窗格输入和终端尺寸,并提供可见的终端内容和图形。其他已连接机器仍会更新工作区信息、智能体状态和通知,但不传输窗格画面。 -启动时会立即打开 Local,不等待 SSH 连接。一台机器无响应不会阻塞其他机器的输入。多个 Herdr 客户端也可以独立查看同一服务器上的不同标签页;共享标签页的尺寸规则见[客户端与服务器](/zh-cn/docs/concepts/)。 +点击任意机器旁的箭头,可以折叠或展开其工作区列表,而不切换当前工作区。机器正在重连时也可以操作。 + +键盘导航先按 `prefix+w`,再使用工作区导航键(默认为 Up/Down),按侧边栏顺序高亮已连接机器上的工作区。Enter 确认切换;Esc 或前缀键取消,不切换机器。紧凑和展开的侧边栏都会显示高亮行,包括折叠机器内的行。桌面导航在两端循环,移动端切换器在首尾停止。已断开的机器会被跳过。 + +高亮其他机器的工作区后,请先按 Enter 再使用其他键盘操作,避免误对当前机器执行窗格、标签页、工作区或自定义命令。点击会取消桌面上的远程键盘导航预览。展开的侧边栏会保留每台机器的 worktree 分组折叠状态。 + +启动时会立即打开 Local,不等待 SSH 连接。选择 Local 也会立即取消尚未完成的远程切换,无需等待远程响应。如果 Local 自身正在重连,会在就绪后继续选中它。收到 Local 的新画面和终端设置后即可输入。一台机器无响应不会阻塞其他机器的输入。多个 Herdr 客户端也可以独立查看同一服务器上的不同标签页;共享标签页的尺寸规则见[客户端与服务器](/zh-cn/docs/concepts/)。 断线后,最后的工作区和智能体状态仍然可见,但会变暗。这是缓存,不是实时状态。在新连接和对应画面到达前,不能输入或导航到这些缓存窗格。重连不会从正在使用的机器抢走选择。 @@ -67,10 +73,12 @@ herdr machine remove <profile-id> ## 连接问题 -- **Reconnecting:** 网络中断、睡眠或 SSH 故障后,Herdr 会以有上限的退避间隔重试。SSH 连接会监测应用层活动并在空闲时探测,避免已断开的连接一直显示 Online。Local 使用本地连接关闭或失败来判断断线,而不是远程健康探测。 +- **Reconnecting:** 网络中断、睡眠或 SSH 故障后,Herdr 会自动重试。连续失败会将间隔增加到最多两分钟,短暂连接成功不会重置间隔。连接稳定一分钟后,下一次中断才会恢复快速重试。SSH 连接会监测应用层活动并在空闲时探测,避免已断开的连接一直显示 Online。Local 使用本地连接关闭或失败来判断断线,而不是远程健康探测。 - **Attention:** 目标需要无法在后台完成的操作,例如批准主机密钥、认证或准备兼容服务器。其他机器仍可使用。 - **保存机器文件错误:** 目录文件无法读取或内容无效时,当前连接保持不变。Herdr 会显示通知并自动重试读取。 +当两端都支持空闲桥接清理时,连接 Linux/macOS 的保存机器会在双向均无流量一分钟后关闭远程桥接。睡眠时间也计入期限,并在唤醒时检查。正常的空闲连接会交换健康检查,持续查看输出无需键盘输入。清理不会停止远程 Herdr 服务器、会话或窗格进程。旧安装仍可兼容连接,只是不使用这项可选清理功能。 + 后台连接不会回答提示,也不会安装、更新、重启或交接服务器。对于 Attention,请在交互式终端运行 Herdr 显示的独立设置命令,例如: ```bash diff --git a/docs/preview/website/src/content/docs/zh-cn/integrations.mdx b/docs/preview/website/src/content/docs/zh-cn/integrations.mdx index f6d7c1f3..5eb1f322 100644 --- a/docs/preview/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/integrations.mdx @@ -287,6 +287,8 @@ Herdr 用 `qwen --resume <id>` 恢复保存的 Qwen Code 会话。 ## Letta Code +Letta Code 集成安装是实验性的 CLI 专用功能,不在 Settings 的集成列表或 JSON 集成 API 中提供。`herdr integration status` 将其显示为 `letta (experimental)`。智能体检测和 `herdr agent start --kind letta` 不受此安装方式限制,均已支持。 + 安装 Letta Code 钩子: ```bash diff --git a/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx b/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx index da795378..486886f9 100644 --- a/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/keyboard.mdx @@ -62,6 +62,26 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智 完整键位和绑定语法见[按键绑定参考](/zh-cn/docs/configuration/#按键绑定)。 +Goto 选择器 (`prefix+g`) 按工作区分组,每个代理或终端各占一行,分割标签页中的窗格也会直接显示。点击一行或按回车即可跳转到对应窗格。按 `/` 搜索名称、代理类型、标签页、工作区、分支或路径;未输入搜索时,用上下方向键或 `j/k` 在各行之间移动,左右方向键跳到上一个或下一个工作区中的第一个代理或终端,到达两端后不会循环。编辑搜索内容时,左右方向键只移动文本光标。用 `b/w/i/d` 筛选 blocked、working、idle 或 done 状态的代理,按 `a` 重新显示所有终端。所选目标的位置和路径显示在列表下方。列表超出可视区域时,可以使用鼠标滚轮、点击滚动条轨道或拖动滑块来滚动,而不会打开目标。 + +## 编辑文本输入框 + +Herdr 的命名对话框、worktree 分支名、筛选框和复制模式搜索框支持在光标处编辑。以下快捷键适用于 Herdr 自己的输入框,不改变窗格内 shell 或智能体的按键行为。 + +| 操作 | 按键 | +| --- | --- | +| 移动一个字符 | Left / Right、Ctrl+B / Ctrl+F | +| 移到开头 / 末尾 | Home / End、Ctrl+A / Ctrl+E | +| 移动一个词 | Alt+B / Alt+F | +| 删除前一个字符 | Backspace、Ctrl+H | +| 删除后一个字符 | Delete、Ctrl+D | +| 剪切到开头 / 末尾 | Ctrl+U / Ctrl+K | +| 剪切前一个词 | Ctrl+W、Alt+Backspace、Ctrl+Backspace | +| 剪切后一个词 | Alt+D | +| 插入上次剪切的文本 | Ctrl+Y | + +输入和粘贴会插入光标所在位置。移动和删除遵循 Unicode 字素边界,长内容会水平滚动。剪切的文本只保留在当前输入框中,不使用系统剪贴板。Alt 快捷键需要终端报告 Alt/Meta。Enter 和 Esc 仍用于对话框的确认和取消。 + ## 复制模式 按 `prefix+[` 让聚焦的窗格进入复制模式。用 `h/j/k/l`、tmux 风格的 `w/b/e` 和 `{`/`}` 移动。按 `/` 或 `?` 向前或向后进行文本搜索,再用 `n` 或 `N` 按相同或相反方向重复搜索。查询包含大写字母时区分大小写,否则不区分。用 `v` 或空格开始选择,用 `y` 或回车复制,用 `q` 或 Esc 不复制直接退出。Esc 会先清除当前选择或搜索,然后才退出。复制模式不会暂停窗格进程:停留在底部时继续跟随输出,进入历史记录后保持当前位置。鼠标拖选可以直接复制,完全不用进入复制模式。 diff --git a/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx index 65567bc3..ddd0b0fb 100644 --- a/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -69,7 +69,7 @@ Host workbox herdr --remote workbox ``` -远程连接支持 Linux、macOS 和 Windows 本地客户端连接 x86_64 与 aarch64 的 Linux 和 macOS 主机。Herdr 会检查远程平台,优先使用远程 `PATH` 上兼容的 `herdr`,然后检查常见的直接安装、Homebrew、mise 和 Nix profile 路径。只要两端都支持稳定端点代数,客户端和服务器的发行版本就不必一致。如果没有兼容的二进制文件,交互式运行会询问是否安装到 `~/.local/bin/herdr`;非交互式运行则直接失败,不会修改主机。如果 `~/.local/bin` 不在远程 `PATH` 上,Herdr 会在安装后发出警告。Windows 不能作为远程主机。 +远程连接和已保存的 SSH 机器支持 Linux、macOS 和 Windows 客户端,目标可以是 x86_64 或 aarch64 的 Linux/macOS 主机,也可以是 x86_64 的 Windows 主机。只要两端支持稳定端点代数,版本就不必一致。Linux/macOS 上优先使用远程 `PATH` 中兼容的 `herdr`,再检查常见的直接安装、Homebrew、mise 和 Nix profile 路径。Windows 上检查 `PATH` 和当前有效的托管版本。如果没有兼容的二进制文件,交互式运行会询问是否安装或更新;非交互式运行直接失败,不修改主机。Linux/macOS 安装到 `~/.local/bin/herdr`,Windows 安装包含应用本地 ConPTY 运行时的完整软件包。如果 `~/.local/bin` 不在远程 `PATH` 中,Herdr 会发出警告。 默认情况下,`herdr --remote` 通过一个临时 SSH 配置运行远程设置和桥接: 先包含你的 SSH 配置,再补充兜底的保活设置。已有的用户保活设置优先。Linux 和 macOS 客户端还会添加一个私有的单次连接 control socket 来复用连接;Windows OpenSSH 不使用此方式。设置 `[remote].manage_ssh_config = false` 可以不用 Herdr 生成的配置或 control socket,而使用普通 `ssh`。 @@ -90,9 +90,9 @@ herdr --remote workbox --handoff 如果你先 SSH 到服务器再在那里运行 `herdr`,Herdr 就完全运行在服务器上。这种模式简单实用,但除了普通的终端文本粘贴之外,它无法访问你本地桌面的剪贴板。 -当本地和远程平台一致时,对于直接安装,Herdr 可以直接复制当前本地二进制文件。对于 Homebrew、mise 和 Nix 安装,或平台不一致时,它会从 `https://herdr.dev/latest.json` 下载与当前客户端版本匹配的发布产物。 +对于 Linux/macOS 主机,平台一致的直接安装可以复制当前本地二进制文件。Homebrew、mise、Nix 安装、平台不一致或目标为 Windows 时,会从 `https://herdr.dev/latest.json` 下载与当前客户端版本对应的发布产物。 -对于本地构建或自定义二进制文件,在远程连接前设置 `HERDR_REMOTE_BINARY` 指向本地文件路径。 +对于本地构建或自定义软件包,在远程连接前设置 `HERDR_REMOTE_BINARY` 指向本地文件。Linux/macOS 目标使用 Herdr 可执行文件,Windows 目标必须使用完整的 `herdr-windows-x86_64.zip` 软件包。 ```bash HERDR_REMOTE_BINARY=target/release/herdr herdr --remote workbox diff --git a/docs/preview/website/src/content/docs/zh-cn/quick-start.mdx b/docs/preview/website/src/content/docs/zh-cn/quick-start.mdx index 3f695d92..87211e28 100644 --- a/docs/preview/website/src/content/docs/zh-cn/quick-start.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/quick-start.mdx @@ -17,9 +17,9 @@ Herdr 会启动或连接到你的默认后台会话。你不需要管理 socket ## 使用鼠标 -Herdr 是鼠标原生的,所以从点击开始。点击窗格、标签页、工作区和智能体来聚焦它们。拖动分割边框来调整大小。右键打开上下文菜单,包括分割窗格和创建标签页。拖选文本即可复制到剪贴板;双击一个词直接复制它。复制不需要 Ctrl+C。 +Herdr 是鼠标原生的,所以从点击开始。点击窗格、标签页、工作区和智能体来聚焦它们。拖动分割边框来调整大小。右键打开上下文菜单,包括分割窗格和创建标签页。拖选文本即可复制到剪贴板。双击一个词可以选中它;按住第二次点击并拖动,会按整词扩展选择。两种操作都会在松开鼠标时复制。复制不需要 Ctrl+C。 -当终端把带修饰键的点击传给 Herdr 时,Ctrl+点击可以打开窗格内的链接。这对 OSC 8 超链接和可见的 `http://` 或 `https://` URL 有效。在 macOS 上,鼠标捕获开启时,请用 Ctrl+点击来触发 Herdr 处理的窗格链接;Cmd+点击只能通过终端原生的绕过路径使用,比如 Shift+Cmd+点击或 `ui.mouse_capture = false`。 +当终端把带修饰键的点击传给 Herdr 时,Ctrl+点击可以打开窗格内的链接。这适用于 OSC 8 超链接及 `http://` 或 `https://` URL,包括首尾部分位于可见区域之外的换行链接。按住 Ctrl 并将指针移到链接上,会为可见部分添加下划线。移开指针、窗格内容变化或收到 Ctrl 释放事件时,高亮会清除;部分终端只在鼠标移动时报告修饰键变化。在 macOS 上,鼠标捕获开启时,请用 Ctrl+点击来触发 Herdr 处理的窗格链接;Cmd+点击只能通过终端原生的绕过路径使用,比如 Shift+Cmd+点击或 `ui.mouse_capture = false`。 如果配置了 `ui.right_click_passthrough_modifier`,该修饰键加右键会把右键点击、按住和拖动手势发送给启用鼠标上报的窗格应用。 diff --git a/docs/preview/website/src/content/docs/zh-cn/session-state.mdx b/docs/preview/website/src/content/docs/zh-cn/session-state.mdx index 9747e2b6..9be84060 100644 --- a/docs/preview/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/session-state.mdx @@ -34,10 +34,29 @@ herdr 快照恢复不会保留运行中的 shell、服务器、测试或任意进程。无法使用更强恢复路径的窗格,会在各自保存的目录中作为新 shell 回来。 -如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。正常加载的会话不会创建恢复副本。 +如果保存的目录不可用或 shell 无法启动,窗格会留在布局中并显示错误,而不是消失或切换到主目录。保存的目录和智能体会话引用仍会保留。修复目录或 shell 配置后,重启服务器即可重试;也可以明确关闭窗格将其移除。 + +保存时,如果操作系统支持查询,Herdr 会优先使用正在运行的 shell 的目录,并在 shell 退出后保留最后确认的目录。在其他平台上,则使用 shell 报告的目录。 + +如果无法读取或解析 `session.json`,或该文件需要更新的 Herdr 版本,Herdr 会记录加载失败的原因。在保存或清除新会话之前,Herdr 会将原始文件逐字节保存到 `session.json` 旁的 `session-backups/` 中,并通过 `persist.backup` 记录恢复副本的路径。即使启动时文件不存在,也会在首次保存或清除前再次检查。这些加载失败时的恢复副本与常规快照历史分开管理。 Herdr 保留最新的三个恢复副本,只有在新副本安全写入后才删除旧副本。如果无法保存副本,自动保存和退出保存都不会修改原始文件,并会记录错误,在下一次保存请求时重试。副本不会自动恢复。如需恢复,请停止对应的服务器,将恢复文件复制到该服务器的 `session.json`,然后重启。恢复副本不包含窗格屏幕历史。 +## 关机与快照恢复 + +在使用 systemd-logind 的 Linux 系统上,Herdr 会监听主机关机预告,并请求短暂延迟,以便保存并停止服务器,随后 logind 才继续关机。延迟由操作系统限制,不会无限阻止关机。此保护不适用于没有 logind 的系统、强制关机、断电,或进程在预告到达前已被终止的情况。 + +在所有平台上,Herdr 还会在 `session.json` 旁的 `session-snapshots/` 中保留最多 48 个布局快照。首次保存的布局会立即复制。之后的保存或清除操作会复制之前保存的布局,最多每 15 分钟一次,不重复保存相同内容。此间隔在服务器重启后仍然有效,因此连续的窗格退出或重启不会挤掉所有旧快照。最近的更改可能尚未包含在快照中。 + +正常关闭窗格仍会更新 `session.json`,不会出现恢复提示,也不会自动恢复旧布局。如需手动恢复: + +1. 用 `herdr session list --json` 找到对应的会话目录。 +2. 停止对应的服务器:默认会话用 `herdr server stop`,命名会话用 `herdr session stop <name>`。 +3. 先另存当前的 `session.json`,再将 `session-snapshots/` 中选定的文件复制到 `session.json`。文件修改时间表示快照的保存时间。 +4. 重新启动该会话。 + +快照包含布局和代理会话引用,不包含运行中的进程或窗格屏幕历史。请将其视为私密会话数据。快照写入失败会记录到日志,但不会阻止主会话文件的正常保存。 + ## 窗格屏幕历史回放 窗格屏幕历史在服务器完全重启后恢复最近的终端内容。它恢复的是 Herdr 能展示的内容,而不是原来的进程。 @@ -51,6 +70,8 @@ pane_history = true 开启后,Herdr 把保存的窗格历史存放在 `session.json` 旁边的 `session-history.json` 中。请像对待终端历史一样对待 Herdr 的配置/会话目录。 +只有与保存的布局完全匹配的历史才会回放。来自其他布局的历史,包括手动恢复快照后不再匹配的历史,都会被忽略。无法验证布局的旧版历史文件也会被忽略;新保存的历史可在下次重启时回放。 + ## 智能体原生会话恢复 一些智能体可以恢复它们自己的对话会话。Herdr 可以使用官方集成上报的会话引用,在 Herdr 服务器重启后重新启动受支持的智能体窗格。 @@ -75,6 +96,7 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 | Claude Code | `6` | `claude --resume <id>` | | Codex | `5` | `codex resume <id>` | | Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | +| Grok CLI | `2` | `grok --resume <id>` | | GitHub Copilot CLI | `2` | `copilot --resume=<id>` | | Devin CLI | `2` | `devin --resume <id>` | | Droid | `2` | `droid --resume <id>` | @@ -101,6 +123,8 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 交接保护的是由服务器拥有的长期会话状态:窗格 PTY 和进程、智能体身份与持久元数据,以及替换服务器所需的插件/会话状态。它不会跨替换边界保留临时协调状态。进行中的 CLI 或 API 请求、等待、订阅流、客户端套接字和窗格间消息可能会中断;客户端应重新连接并重试。 +更新后的 Unix 服务器可以分批传输,在一次交接中发送超过 64 个窗格。发送方服务器必须已经支持此功能;从旧服务器进行第一次升级时,仍受其原有的 64 窗格限制。此时可能需要正常重启服务器,这会结束窗格进程。分批传输不会改变更新的安装顺序,也不会取消其他交接兼容性要求。 + 实时交接是实验性功能,需要主动开启: ```bash diff --git a/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx b/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx index 9a2f5c4e..3aae7131 100644 --- a/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx @@ -111,7 +111,7 @@ herdr pane read w1:p2 --source recent --lines 50 `workspace.move_block` 会将有序的 `workspace_ids` 原子地移动到 `before_workspace_id` 之前;省略锚点则将该块移动到末尾。id 必须唯一,且锚点不能属于被移动的块。响应包含服务器确认的有序工作区列表。 -`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。为避免引导期间漏掉事件,应先在另一个连接上打开 `events.subscribe` 并等待确认,在调用 `session.snapshot` 时缓冲该事件流,然后安装快照、按顺序应用缓冲的事件并继续接收事件。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。 +`session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅。初始化或恢复缓存时,应先建立订阅,再按下文事件订阅部分的说明读取服务器当前状态并核对、更新缓存。事件表示缓存可能过期,不能无条件重放到快照上。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。 CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSON,让客户端和智能体可以简单地完成引导。 @@ -345,7 +345,7 @@ UI 上下文变化时,该投影会重新求值。它控制展开和折叠的侧 过滤字段是 `status`、`workspace_id`、`tab_id`、`pane_id`、`agent`、`seen` 和 `state_change_seq`。要过滤插件上报的窗格元数据,使用 `{"token":"name"}` 作为字段。值可以是字符串、布尔值、无符号整数或上下文对象。上下文值包括 -`current_workspace_id` 和 `current_tab_id`,并且只能与对应的 ID 字段比较。 +`current_workspace_id` 和 `current_tab_id`,并且只能与对应的 ID 字段比较。在保存机器客户端中,所选服务器的视图应用于合并后的智能体列表。当前工作区和标签页上下文也包含所选机器,因此其他机器上的相同 ID 不会匹配;而 `status` 为 `blocked` 等独立条件仍可匹配任意已连接机器上的智能体。 有效 status 值为 `idle`、`working`、`blocked`、`done` 和 `unknown`;`done` 表示 idle 且尚未查看。 @@ -635,10 +635,16 @@ workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏 第一个响应确认订阅。之后的行是推送的事件。生命周期事件订阅从请求被接受时开始,不会重放在此之前保留的事件。 +生命周期和智能体状态事件按有界批次发送,每个订阅内保持事件顺序。事件历史不是持久日志。如果订阅者落后到保留范围之外,包括订阅初始化期间,服务器会发送包含原请求 `id` 和 `error.code: "events_lost"` 的错误响应,然后关闭该订阅连接,而不是静默跳过缺失事件后继续发送。其他客户端的连接不受影响。各类事件共享历史缓冲,因此即使被淘汰的事件可能不匹配此订阅的过滤条件,仍会报告溢出。 + +收到 `events_lost` 后,将缓存状态视为过期。建立新订阅并等待 `subscription_started`,然后持续读取,同时在另一条连接上请求 `session.snapshot`。通过服务器返回的当前状态重新核对缓存:用快照整体替换缓存,将收到的事件作为缓存失效通知并触发再次读取。串行执行刷新;如果读取期间又收到事件,完成后再次刷新。 + +快照和事件没有共同的序列边界。顺序仅在每个订阅条目内保证,不保证跨条目或相对于快照的顺序,因此不要把缓冲的事件载荷无条件重放到快照上。恢复得到的是当前状态,无法补回丢失的事件历史。再次收到 `events_lost` 时,需要重新订阅并核对状态。读取过慢的客户端可能需要提高读取速度或减少事件负载。 + 工作区事件订阅包括 `workspace.created`、`workspace.updated`、`workspace.metadata_updated`、`workspace.renamed`、`workspace.moved`、`workspace.reordered`、`workspace.closed` 和 `workspace.focused`。`workspace.metadata_updated` 报告令牌变更和 TTL 到期,但不会调用插件事件钩子。其他工作区事件描述 Herdr UI/运行时的生命周期。当工作区属于 worktree 组时,`workspace.created` 包含可选的 `workspace.worktree` 来源信息。`workspace.moved` 包含被移动的 `workspace_id`、请求的 `insert_index` 和更新后的有序 `workspaces` 列表。`workspace.reordered` 包含原子移动的 `workspace_ids`、可选的 `before_workspace_id` 和服务器确认的有序 `workspaces` 列表。在移除前 Herdr 仍能识别时,`workspace.closed` 包含最终的 `workspace` 快照。 标签页事件订阅包括 `tab.created`、`tab.closed`、`tab.focused`、`tab.renamed` 和 `tab.moved`。`tab.moved` 包含被移动的 `tab_id`、`workspace_id`、请求的 `insert_index` 和该工作区更新后的有序 `tabs` 列表。 -窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。 -布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 引导的客户端应替换具有相同 `workspace_id` 和 `tab_id` 的缓存布局。 +窗格事件订阅包括 `pane.created`、`pane.updated`、`pane.closed`、`pane.focused`、`pane.moved`、`pane.exited`、`pane.agent_detected`、`pane.output_matched`、`pane.agent_status_changed` 和 `pane.scroll_changed`。`pane.focused` 也会上报任意连接到此服务器的客户端手动选择窗格的变化,不会移动其他客户端的视图,且载荷不包含客户端身份。重复选择已选中的窗格不会再次发出事件。终端标题变化可能发出 `pane.updated`,但如果原始标题只有旋转指示符发生变化且 `terminal_title_stripped` 不变,则不会发出。`pane.scroll_changed` 只针对一个 `pane_id`,每当 Herdr 观察到滚动快照变化时,都会发出 `pane_id`、`workspace_id` 和当前 `scroll` 指标。 +布局事件订阅包括 `layout.updated`。该事件携带一个标签页更新后的 `PaneLayoutSnapshot`。使用 `session.snapshot` 维护缓存的客户端应将该事件作为重新读取受影响布局的通知;延迟到达的载荷可能早于最新快照。 Worktree 事件订阅包括 `worktree.created`、`worktree.opened` 和 `worktree.removed`。Worktree 事件描述 Git 检出的生命周期。`worktree.created` 包含打开的 `workspace` 和创建的 `worktree`。`worktree.opened` 包含目标 `workspace`、打开的 `worktree` 和 `already_open`。`worktree.removed` 包含 `workspace_id`、被移除的 `worktree` 和 `forced`。 diff --git a/docs/preview/website/src/content/docs/zh-cn/windows-beta.mdx b/docs/preview/website/src/content/docs/zh-cn/windows-beta.mdx index 35b1ff33..025f0f4d 100644 --- a/docs/preview/website/src/content/docs/zh-cn/windows-beta.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/windows-beta.mdx @@ -32,7 +32,7 @@ Windows 构建同时通过稳定版和预览版更新通道提供。新安装默 | 本地持久会话 | 已支持 | | 通过 ConPTY 的原生窗格 | 已支持 | | Windows Terminal / PowerShell 应用连接 | 已支持 | -| 连接 Linux/macOS 主机的 `herdr --remote` | 已支持 | +| 连接 Linux/macOS/Windows 主机的 `herdr --remote` 和已保存 SSH 机器 | 已支持;交互式连接和机器设置可在确认后安装或更新 Windows 软件包,后台重连只查找已安装的软件包 | | 远程剪贴板图像和图像文件拖放 | 已支持 | | `cmd.exe` 窗格 | 已支持 | | 原生键盘和鼠标输入 | 已支持 | @@ -80,7 +80,7 @@ switch_ascii_input_source_in_prefix = true 此选项暂不支持其他 Windows 输入法。参见[配置](/zh-cn/docs/configuration/#前缀输入源切换)。 -Windows 上的部分智能体可以接收 `ctrl+v` 并直接读取剪贴板图像。Herdr 自己的剪贴板图像读取器尚未接入本地原生 Windows 窗格,因此智能体原生图像粘贴仍取决于终端和智能体。`alt+v` 等智能体图像粘贴快捷键不会增加由 Herdr 管理的本地剪贴板桥。远程剪贴板图像桥通过连接 Linux/macOS 主机的 `herdr --remote` 单独受支持。 +Windows 上的部分智能体可以接收 `ctrl+v` 并直接读取剪贴板图像。Herdr 自己的剪贴板图像读取器尚未接入本地原生 Windows 窗格,因此智能体原生图像粘贴仍取决于终端和智能体。`alt+v` 等智能体图像粘贴快捷键不会增加由 Herdr 管理的本地剪贴板桥。远程剪贴板图像桥通过连接 Linux/macOS/Windows 主机的 `herdr --remote` 单独受支持。 Kitty graphics 默认启用,并取决于外层终端。Herdr 会像在其他平台上一样在 Windows 上输出 Kitty graphics protocol。这个路径已在 Windows WezTerm 托管 WSL 中 Herdr 的配置下得到验证,但原生 Windows 的各种终端与 ConPTY 组合尚未全部验证。Windows Terminal 不提供 Herdr 使用的 Kitty graphics 路径。如果外层终端无法正确处理图形输出,请设置 `[terminal].kitty_graphics = false`。 @@ -118,7 +118,6 @@ Herdr 的窗格文本复制在 Windows 上可用。在窗格内拖选文本即 | 能力 | 状态 | | --- | --- | | 直接终端附加 (`herdr terminal attach`) | 不支持 | -| Windows 作为 `herdr --remote` 的目标主机 | 不支持 | | 实时服务器交接 | 不支持 | | Unix 文件描述符交接 | 不支持 | | Unix 前台进程组 | 不支持 | @@ -131,7 +130,7 @@ Herdr 的窗格文本复制在 Windows 上可用。在窗格内拖选文本即 herdr --remote workbox ``` -目标主机必须运行 Linux 或 macOS。Herdr 使用已安装的 Windows OpenSSH 客户端和你的 SSH 配置。Windows OpenSSH 不使用 Herdr 的 Unix control socket 连接复用,因此建议通过 Windows `ssh-agent` 使用密钥认证,避免远程设置期间重复提示。 +目标主机可以运行 Linux、macOS 或 Windows。Windows 主机会复用 `PATH` 或当前有效的托管版本中的兼容软件包。交互式连接和保存机器设置会在安装或更新完整软件包前询问;后台重连只查找已安装的软件包,不会提示安装或更新。Herdr 使用已安装的 Windows OpenSSH 客户端和你的 SSH 配置。Windows OpenSSH 不使用 Herdr 的 Unix control socket 连接复用,因此建议通过 Windows `ssh-agent` 使用密钥认证,避免远程设置期间重复提示。 Windows 更新通过 Windows 安装器进行,并更新当前带版本号的发布路径。新终端和重新连接的 SSH 会话会获得该路径;在其中启动 Herdr 即可使用更新后的客户端。兼容的运行中服务器会让窗格继续运行。只有需要新版本的服务器端改动时,才稍后重启服务器。实时交接仅限 Unix。 diff --git a/docs/preview/website/src/data/config-reference.json b/docs/preview/website/src/data/config-reference.json index 36afe337..ece9a833 100644 --- a/docs/preview/website/src/data/config-reference.json +++ b/docs/preview/website/src/data/config-reference.json @@ -410,7 +410,7 @@ "key": "terminal.default_shell", "type": "string", "default": "\"\"", - "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh." + "description": "Executable used for new interactive panes. Empty means SHELL, then /bin/sh; on Windows, pwsh.exe when present on PATH, otherwise powershell.exe." }, { "key": "terminal.shell_mode", @@ -697,6 +697,12 @@ "default": "\"prefix+e\"", "description": "Open the focused pane scrollback in $EDITOR." }, + { + "key": "keys.clear_pane", + "type": "keybinding", + "default": "unset", + "description": "Clear the focused pane screen and scrollback, keeping the current cursor line and its visible soft-wrapped rows. Sends no input to the running program and does nothing on the alternate screen (for example, Vim). Unset by default; for example, clear_pane = \"prefix+ctrl+k\"." + }, { "key": "keys.copy_mode", "type": "keybinding", From 7e91c4cd933201e1f578f89e87cd8b20bc87c658 Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Mon, 21 Sep 2026 08:13:34 +0800 Subject: [PATCH 36/99] perf: index navigator tabs and panes per endpoint (#4426) --- src/client/shell/aggregate_navigation.rs | 36 ++++-- src/client/shell/tests/copy.rs | 153 +++++++++++++++++++---- 2 files changed, 157 insertions(+), 32 deletions(-) diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index 9d3108cc..b1a4d35f 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -304,23 +304,39 @@ pub(super) fn navigator_rows( .iter() .map(|agent| (agent.pane_id.as_str(), agent)) .collect::<HashMap<_, _>>(); + // Build endpoint-local indexes once. Walk each bucket in snapshot + // order so interleaved input and overlapping IDs on other endpoints + // retain their existing navigation order and targets. + let mut tabs_by_workspace = HashMap::new(); + for tab in &snapshot.tabs { + tabs_by_workspace + .entry(tab.workspace_id.as_str()) + .or_insert_with(Vec::new) + .push(tab); + } + let mut panes_by_tab = HashMap::new(); + for pane in &snapshot.panes { + panes_by_tab + .entry(pane.tab_id.as_str()) + .or_insert_with(Vec::new) + .push(pane); + } for workspace in &snapshot.workspaces { let workspace_matches = endpoint_query_matches || text(&workspace.label) || workspace.branch.as_deref().is_some_and(text); let mut children = Vec::new(); - let workspace_tabs = snapshot - .tabs - .iter() - .filter(|tab| tab.workspace_id == workspace.workspace_id); - let multiple_tabs = workspace_tabs.clone().nth(1).is_some(); + let workspace_tabs = tabs_by_workspace + .get(workspace.workspace_id.as_str()) + .map(Vec::as_slice) + .unwrap_or_default(); + let multiple_tabs = workspace_tabs.len() > 1; for tab in workspace_tabs { let tab_matches = workspace_matches || text(&tab.label); - let tab_panes = snapshot - .panes - .iter() - .filter(|pane| pane.tab_id == tab.tab_id) - .collect::<Vec<_>>(); + let tab_panes = panes_by_tab + .get(tab.tab_id.as_str()) + .map(Vec::as_slice) + .unwrap_or_default(); for (index, pane) in tab_panes.iter().enumerate() { let agent = agents.get(pane.pane_id.as_str()).copied(); let status = agent diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index bdd192a6..617af89b 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1534,32 +1534,141 @@ fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() { } } +fn navigator_scale_snapshot(workspaces: usize, tabs: usize, panes: usize) -> ClientShellSnapshot { + let mut result = snapshot(); + let workspace_template = result.workspaces[0].clone(); + let tab_template = result.tabs[0].clone(); + let pane_template = result.panes[0].clone(); + result.workspaces.clear(); + result.tabs.clear(); + result.panes.clear(); + for w in 0..workspaces { + let mut workspace = workspace_template.clone(); + workspace.workspace_id = format!("workspace_{w}"); + workspace.active_tab_id = format!("tab_{w}_0"); + workspace.number = w + 1; + workspace.label = format!("workspace {w}"); + workspace.focused = w == 0; + for t in 0..tabs { + let mut tab = tab_template.clone(); + tab.workspace_id = workspace.workspace_id.clone(); + tab.tab_id = format!("tab_{w}_{t}"); + tab.number = t + 1; + tab.label = format!("tab {t}"); + tab.focused = w == 0 && t == 0; + for p in 0..panes { + let mut pane = pane_template.clone(); + pane.workspace_id = workspace.workspace_id.clone(); + pane.tab_id = tab.tab_id.clone(); + pane.pane_id = format!("pane_{w}_{t}_{p}"); + pane.label = Some(format!("terminal {p}")); + pane.focused = w == 0 && t == 0 && p == 0; + result.panes.push(pane); + } + result.tabs.push(tab); + } + result.workspaces.push(workspace); + } + result.focused_workspace_id = Some(result.workspaces[0].workspace_id.clone()); + result.focused_tab_id = Some(result.tabs[0].tab_id.clone()); + result.focused_pane_id = Some(result.panes[0].pane_id.clone()); + result +} + +#[test] +fn navigator_grouping_keeps_snapshot_order_with_interleaved_tabs_and_panes() { + let mut snapshot = navigator_scale_snapshot(2, 2, 2); + snapshot.tabs.swap(1, 2); + snapshot.panes.reverse(); + let expected = snapshot + .workspaces + .iter() + .flat_map(|workspace| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + .flat_map(|tab| { + snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .map(|pane| pane.pane_id.clone()) + }) + }) + .collect::<Vec<_>>(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let remote = SavedSshEndpoint::new("Remote", "dev@example.invalid", "test").unwrap(); + let remote_id = ClientEndpointId::Ssh(remote.id.clone()); + state.set_endpoint_catalog(&[remote]); + state.set_endpoint_status(&remote_id, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&remote_id, Box::new(snapshot.clone())); + state.set_snapshot(Box::new(snapshot)); + state.open_navigator_overlay(); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_ref() else { + panic!("navigator") + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let actual = rows + .iter() + .filter_map(|row| match &row.target { + ClientNavigatorTarget::Pane { + endpoint_id, + pane_id, + } => { + assert!(endpoint_id == &state.active_endpoint_id || endpoint_id == &remote_id); + Some((endpoint_id.clone(), pane_id.clone())) + } + _ => None, + }) + .collect::<Vec<_>>(); + let expected = [state.active_endpoint_id.clone(), remote_id] + .into_iter() + .flat_map(|endpoint| { + expected + .iter() + .map(move |pane| (endpoint.clone(), pane.clone())) + }) + .collect::<Vec<_>>(); + assert_eq!(actual, expected); +} + #[test] #[ignore = "manual navigator composition scaling profile"] fn navigator_render_scale_profile() { - for panes in [1, 15, 52] { - let mut snapshot = snapshot(); - for index in 1..panes { - let mut pane = snapshot.panes[0].clone(); - pane.pane_id = format!("pane_{index}_extra"); - pane.focused = false; - snapshot.panes.push(pane); - } - let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); - state.set_snapshot(Box::new(snapshot)); - state.set_pane_surface(surface()); - state.open_navigator_overlay(); - for _ in 0..20 { - std::hint::black_box(state.compose(106, 30).expect("navigator frame")); - } - let start = std::time::Instant::now(); - for _ in 0..1000 { - std::hint::black_box(state.compose(106, 30).expect("navigator frame")); + for (workspaces, tabs, panes) in [ + (1, 1, 1), + (1, 1, 15), + (1, 1, 52), + (1, 1, 512), + (1, 128, 4), + (64, 2, 4), + (128, 4, 1), + ] { + for query in ["", "terminal 0"] { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(navigator_scale_snapshot(workspaces, tabs, panes))); + let mut pane_surface = surface(); + pane_surface.panes[0].pane_id = "pane_0_0_0".into(); + state.set_pane_surface(pane_surface); + state.open_navigator_overlay(); + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.query = query.into(); + } + for _ in 0..20 { + std::hint::black_box(state.compose(106, 30).expect("navigator frame")); + } + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(state.compose(106, 30).expect("navigator frame")); + } + eprintln!( + "navigator: {workspaces}x{tabs}x{panes}, query={query:?}, {:.1} us/frame", + start.elapsed().as_secs_f64() * 1000.0 + ); } - eprintln!( - "navigator: {panes} panes, {:.1} us/frame", - start.elapsed().as_secs_f64() * 1000.0 - ); } } From 5a649142233631f8407b4099da0e8e78dfef8574 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik <ogulcancelik@gmail.com> Date: Mon, 21 Sep 2026 03:56:58 +0300 Subject: [PATCH 37/99] chore: approve ain3sh contributor accounts --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 621cf5f4..5671116c 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -72,3 +72,5 @@ caner-akca markjaquith JJLiebig vinayshah1998 +ain3sh +factory-ain3sh From 3602c757e07645383dc092f8540dcb72ba0ed49d Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Mon, 21 Sep 2026 15:22:46 +0300 Subject: [PATCH 38/99] fix: refresh forwarded ssh agents after reconnect (#4443) refs #1931 --- docs/next/api/herdr-api.schema.json | 33 ++ .../src/content/docs/connecting-machines.mdx | 4 + src/api/schema.rs | 2 + src/api/schema/server.rs | 9 + src/api/schema/tests.rs | 1 + src/api/server.rs | 136 ++++++- src/app/api.rs | 7 + src/cli/status.rs | 10 + src/pane.rs | 2 + src/platform/mod.rs | 3 + src/platform/ssh_agent.rs | 361 ++++++++++++++++++ src/remote.rs | 2 + src/remote/host.rs | 2 + src/remote/ssh_agent.rs | 239 ++++++++++++ src/update.rs | 3 + 15 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 src/platform/ssh_agent.rs create mode 100644 src/remote/ssh_agent.rs diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index ea888dc3..bd758e9e 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -3965,6 +3965,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" + }, "SplitDirection": { "enum": [ "right", @@ -4817,6 +4829,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": { @@ -10638,6 +10666,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/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 3f7bcfe6..8a7f5c7c 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -91,6 +91,10 @@ Use your profile's target. If you chose a named session when adding it, include If authentication fails, check ordinary SSH first. For a passphrase-protected key, load it with `ssh-add` before starting Herdr's non-interactive background connections. +For Git authentication or SSH signing inside remote panes, enable `ForwardAgent yes` for the trusted host in your SSH config. Herdr does not enable forwarding for you. When a session starts with an agent, updated Linux and macOS servers give panes a stable agent address, so existing and new panes can use the forwarded agent after `--remote` or a saved machine reconnects. A working inherited agent or earlier connection keeps priority over later clients and temporary setup checks; if it disappears, another live attachment can supply the agent. + +This requires an updated remote server, not just an updated local client. Local sessions started without an agent leave `SSH_AUTH_SOCK` alone. Panes created before the server update, or before an agent was first supplied to the session, keep their original environment and need to be recreated once to inherit the stable address. Older compatible servers and connections without agent forwarding can still attach normally. + ## Settings and automation The UI uses the client's local theme, sidebar settings, and keybindings by default. Custom commands and plugins advertised by the selected server still run there. Herdr does not copy local command plugins, configuration, executables, or secrets onto SSH hosts. Missing remote commands fail visibly. Use the UI's `reload config` action after editing client settings; see [Configuration](/docs/configuration/#reload-config). diff --git a/src/api/schema.rs b/src/api/schema.rs index e6c678dc..d7e1a950 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")] 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<String>, } +#[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 4f15b322..3b43133b 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, }), }, }; diff --git a/src/api/server.rs b/src/api/server.rs index 23817fff..edb778f7 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -74,13 +74,14 @@ fn default_capabilities() -> Option<ServerCapabilities> { 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<ServerCapabilities>, + mut capabilities: Option<ServerCapabilities>, server_stop: Option<Arc<AtomicBool>>, ) -> std::io::Result<ServerHandle> { let path = socket_path(); @@ -91,6 +92,31 @@ 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 || { @@ -102,6 +128,8 @@ fn start_server_inner( 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, @@ -110,6 +138,8 @@ fn start_server_inner( &connection_running, capabilities, server_stop.as_ref(), + #[cfg(unix)] + ssh_agents.as_ref(), ) { warn!(err = %err, "api connection failed"); } @@ -153,7 +183,16 @@ fn handle_connection( running: &Arc<AtomicBool>, capabilities: Option<ServerCapabilities>, ) -> 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( @@ -163,6 +202,7 @@ fn handle_connection_with_stop( running: &Arc<AtomicBool>, capabilities: Option<ServerCapabilities>, server_stop: Option<&Arc<AtomicBool>>, + #[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"); @@ -213,6 +253,49 @@ fn handle_connection_with_stop( crate::logging::api_request_started(&request_id, method, changes_ui); match request.method { + #[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, + } + } + Ok(()) + } Method::PaneGraphicsStream(params) => { let result = pane_graphics_stream::serve(stream, request_id.clone(), params, api_tx, running); @@ -404,6 +487,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", @@ -1025,6 +1109,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, @@ -1185,6 +1316,7 @@ mod tests { ), surface_interest: true, health_check: true, + ssh_agent_registration: false, }), None, None, diff --git a/src/app/api.rs b/src/app/api.rs index 0900a09d..1b5bad71 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -899,6 +899,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, diff --git a/src/cli/status.rs b/src/cli/status.rs index d13be784..826c8ea6 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -280,6 +280,7 @@ struct ServerCapabilitiesJson { endpoint_protocol_generation: Option<u32>, surface_interest: bool, health_check: bool, + ssh_agent_registration: bool, } #[derive(Serialize)] @@ -325,6 +326,7 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson { endpoint_protocol_generation: capabilities.endpoint_protocol_generation, surface_interest: capabilities.surface_interest, health_check: capabilities.health_check, + ssh_agent_registration: capabilities.ssh_agent_registration, }), compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION), endpoint_compatible: capabilities.as_ref().and_then(|capabilities| { @@ -422,10 +424,18 @@ mod tests { endpoint_protocol_generation: endpoint_generation, surface_interest: true, health_check: true, + ssh_agent_registration: false, }), } } + #[test] + fn status_exposes_ssh_agent_registration() { + let server = running_server(Some("test"), None); + let value = serde_json::to_value(server_status_json(&server)).unwrap(); + assert_eq!(value["capabilities"]["ssh_agent_registration"], false); + } + #[test] fn stale_compatible_server_does_not_require_restart() { let server = running_server( diff --git a/src/pane.rs b/src/pane.rs index 099580c3..485b226c 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -146,6 +146,8 @@ impl PaneLaunchEnv { } fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) { + #[cfg(unix)] + crate::platform::ssh_agent::apply_pane_env(cmd); cmd.env_remove("CODEX_THREAD_ID"); // OMP sets OMPCODE for shells it spawns. A pane launched from inside OMP // must not inherit it or its root agent would look like a nested session. diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 488c5bee..864516da 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,9 @@ //! Centralizes OS-dependent behavior behind a clean boundary so core //! modules don't scatter `#[cfg]` branches through product logic. +#[cfg(unix)] +pub(crate) mod ssh_agent; + pub(crate) struct HostShutdownMonitor { task: Option<tokio::task::JoinHandle<()>>, } diff --git a/src/platform/ssh_agent.rs b/src/platform/ssh_agent.rs new file mode 100644 index 00000000..9caddd73 --- /dev/null +++ b/src/platform/ssh_agent.rs @@ -0,0 +1,361 @@ +//! Session-local indirection for SSH agents whose sockets belong to an attachment. + +use std::fs; +use std::io; +use std::os::unix::fs::{symlink, FileTypeExt, MetadataExt}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use interprocess::local_socket::{ConnectOptions, GenericFilePath, ToFsName}; +use interprocess::ConnectWaitMode; + +use crate::ipc::LocalStream; + +const PROBE_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Clone)] +pub(crate) struct SshAgentRegistry(Arc<Mutex<State>>); + +struct State { + path: PathBuf, + fallback: Option<PathBuf>, + agents: Vec<(u64, PathBuf)>, + next_id: u64, + identity: Option<(u64, u64)>, + last_probe: Option<Instant>, +} + +pub(crate) struct SshAgentLease { + registry: SshAgentRegistry, + id: u64, +} + +pub(crate) fn socket_path() -> PathBuf { + agent_path_for(&crate::api::socket_path()) +} + +fn agent_path_for(api_path: &Path) -> PathBuf { + let mut path = api_path.as_os_str().to_os_string(); + path.push(".agent"); + path.into() +} + +fn usable_socket(path: &Path) -> bool { + fs::metadata(path).is_ok_and(|metadata| { + // The API is user-private; do not redirect that user's panes to another user's agent. + metadata.file_type().is_socket() && metadata.uid() == unsafe { libc::geteuid() } + }) +} + +fn live_socket(path: &Path) -> bool { + if !usable_socket(path) { + return false; + } + let Ok(name) = path.to_fs_name::<GenericFilePath>() else { + return false; + }; + // Never wait for a full accept queue or retain a forwarded SSH channel after probing. + let Ok(LocalStream::UdSocket(stream)) = ConnectOptions::new() + .name(name) + .wait_mode(ConnectWaitMode::Timeout(Duration::ZERO)) + .nonblocking_stream(true) + .connect_sync() + else { + return false; + }; + // Linux can report an unconnected socket writable after connect returns EAGAIN. + stream.inner().peer_addr().is_ok() +} + +impl SshAgentRegistry { + pub(crate) fn new(path: PathBuf, inherited: Option<PathBuf>) -> io::Result<Self> { + let inherited = inherited.filter(|path| !path.as_os_str().is_empty()); + let managed = inherited.is_some() + || fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_symlink()); + // A handoff keeps existing pane environments, including this stable pathname. + let fallback = fs::read_link(&path) + .ok() + .filter(|target| target != &path && usable_socket(target)) + .or_else(|| inherited.filter(|target| target != &path && usable_socket(target))); + let mut state = State { + path, + fallback, + agents: Vec::new(), + next_id: 0, + identity: None, + last_probe: None, + }; + if managed { + state.publish()?; + } + Ok(Self(Arc::new(Mutex::new(state)))) + } + + pub(crate) fn register(&self, path: PathBuf) -> io::Result<SshAgentLease> { + let mut state = self + .0 + .lock() + .map_err(|_| io::Error::other("SSH agent registry poisoned"))?; + if !path.is_absolute() || path == state.path || !usable_socket(&path) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SSH agent must be an absolute, user-owned socket", + )); + } + // Canonicalizing /var to /private/var can exceed macOS's Unix socket path limit. + let id = state.next_id; + state.next_id += 1; + state.agents.push((id, path)); + if let Err(error) = state.publish() { + state.agents.retain(|(candidate, _)| *candidate != id); + return Err(error); + } + Ok(SshAgentLease { + registry: self.clone(), + id, + }) + } +} + +impl State { + fn publish(&mut self) -> io::Result<()> { + if let Some(identity) = self.identity { + let metadata = fs::symlink_metadata(&self.path)?; + if identity != (metadata.dev(), metadata.ino()) { + return Err(io::Error::other( + "SSH agent address belongs to a replacement server", + )); + } + } + // Keep a working agent rather than letting probes or a second client replace it. + let unavailable = self.path.with_extension("unavailable"); + self.last_probe = Some(Instant::now()); + let target = self + .fallback + .as_deref() + .filter(|path| live_socket(path)) + .or_else(|| { + self.agents + .iter() + .map(|(_, path)| path.as_path()) + .find(|path| live_socket(path)) + }) + .unwrap_or(&unavailable); + if self.identity.is_some() && fs::read_link(&self.path).ok().as_deref() == Some(target) { + return Ok(()); + } + let temporary = self + .path + .with_extension(format!("{}.new", std::process::id())); + symlink(target, &temporary)?; + if let Err(error) = fs::rename(&temporary, &self.path) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + let metadata = fs::symlink_metadata(&self.path)?; + self.identity = Some((metadata.dev(), metadata.ino())); + Ok(()) + } +} + +impl Drop for State { + fn drop(&mut self) { + if fs::symlink_metadata(&self.path) + .is_ok_and(|metadata| self.identity == Some((metadata.dev(), metadata.ino()))) + { + let _ = fs::remove_file(&self.path); + } + } +} + +impl SshAgentLease { + pub(crate) fn refresh(&self) -> io::Result<()> { + self.refresh_at(Instant::now()) + } + + fn refresh_at(&self, now: Instant) -> io::Result<()> { + let mut state = self + .registry + .0 + .lock() + .map_err(|_| io::Error::other("SSH agent registry poisoned"))?; + // Share the probe budget across attachments, not one SSH channel per polling client. + if state + .last_probe + .is_none_or(|last| now.saturating_duration_since(last) >= PROBE_INTERVAL) + { + state.publish()?; + } + Ok(()) + } +} + +impl Drop for SshAgentLease { + fn drop(&mut self) { + if let Ok(mut state) = self.registry.0.lock() { + state.agents.retain(|(id, _)| *id != self.id); + if let Err(error) = state.publish() { + tracing::warn!(%error, "could not refresh SSH agent after attachment ended"); + } + } + } +} + +pub(crate) fn apply_pane_env(command: &mut portable_pty::CommandBuilder) { + let path = socket_path(); + if fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + command.env("SSH_AUTH_SOCK", path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + + #[test] + fn server_without_an_agent_leaves_local_pane_agent_setup_alone() { + let directory = std::env::temp_dir().join(format!("herdr-no-agent-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let stable = directory.join("agent"); + for inherited in [None, Some(PathBuf::new())] { + let registry = SshAgentRegistry::new(stable.clone(), inherited).unwrap(); + assert!( + fs::symlink_metadata(&stable).is_err(), + "a local server without an agent must not advertise an agent address to panes" + ); + drop(registry); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn closed_agent_listener_does_not_block_a_live_replacement() { + let directory = + std::env::temp_dir().join(format!("herdr-dead-agent-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let stable = directory.join("agent"); + let a = directory.join("a"); + let b = directory.join("b"); + let listener_a = UnixListener::bind(&a).unwrap(); + let _listener_b = UnixListener::bind(&b).unwrap(); + let registry = SshAgentRegistry::new(stable.clone(), Some(a.clone())).unwrap(); + let (mut probe, _) = listener_a.accept().unwrap(); + probe.set_nonblocking(true).unwrap(); + assert_eq!( + std::io::Read::read(&mut probe, &mut [0]).unwrap(), + 0, + "agent checks must not hold forwarded SSH channels open" + ); + let lease_b = registry.register(b.clone()).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), a); + drop(listener_a); + assert!(fs::metadata(&a).unwrap().file_type().is_socket()); + lease_b.refresh_at(Instant::now() + PROBE_INTERVAL).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), b); + drop(lease_b); + drop(registry); + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn unestablished_agent_connection_does_not_block_a_live_replacement() { + use std::os::fd::AsRawFd; + use std::os::unix::net::UnixStream; + let directory = + std::env::temp_dir().join(format!("herdr-busy-agent-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let a = directory.join("a"); + let b = directory.join("b"); + let stable = directory.join("agent"); + let listener_a = UnixListener::bind(&a).unwrap(); + // Linux allows one queued connection with a zero backlog. + assert_eq!(unsafe { libc::listen(listener_a.as_raw_fd(), 0) }, 0); + let _queued = UnixStream::connect(&a).unwrap(); + let _listener_b = UnixListener::bind(&b).unwrap(); + let registry = SshAgentRegistry::new(stable.clone(), Some(a)).unwrap(); + let lease_b = registry.register(b.clone()).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), b); + drop(lease_b); + drop(registry); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn registration_preserves_the_supplied_socket_address() { + let directory = + std::env::temp_dir().join(format!("herdr-agent-path-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + symlink(".", directory.join("alias")).unwrap(); + let _listener = UnixListener::bind(directory.join("upstream")).unwrap(); + let supplied = directory.join("alias/upstream"); + let stable = directory.join("agent"); + let registry = SshAgentRegistry::new(stable.clone(), None).unwrap(); + let lease = registry.register(supplied.clone()).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), supplied); + drop(lease); + drop(registry); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn socket_overrides_have_independent_agent_addresses() { + let directory = + std::env::temp_dir().join(format!("herdr-agent-overrides-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let a = directory.join("a"); + let b = directory.join("b"); + let _a_listener = UnixListener::bind(&a).unwrap(); + let _b_listener = UnixListener::bind(&b).unwrap(); + let stable_a = agent_path_for(&directory.join("first.sock")); + let stable_b = agent_path_for(&directory.join("second.sock")); + let registry_a = SshAgentRegistry::new(stable_a.clone(), Some(a.clone())).unwrap(); + let registry_b = SshAgentRegistry::new(stable_b.clone(), Some(b.clone())).unwrap(); + assert_eq!(fs::read_link(&stable_a).unwrap(), a); + assert_eq!(fs::read_link(&stable_b).unwrap(), b); + drop(registry_a); + assert_eq!(fs::read_link(&stable_b).unwrap(), b); + drop(registry_b); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn reconnect_and_overlapping_attachments_keep_a_stable_agent_address() { + let directory = + std::env::temp_dir().join(format!("herdr-ssh-agent-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let stable = directory.join("agent"); + let a = directory.join("a"); + let b = directory.join("b"); + let probe = directory.join("probe"); + let _a_listener = UnixListener::bind(&a).unwrap(); + let _b_listener = UnixListener::bind(&b).unwrap(); + let _probe_listener = UnixListener::bind(&probe).unwrap(); + let registry = SshAgentRegistry::new(stable.clone(), Some(a.clone())).unwrap(); + let temporary = registry.register(a.clone()).unwrap(); + drop(temporary); + assert_eq!(fs::read_link(&stable).unwrap(), a); + let lease_a = registry.register(a.clone()).unwrap(); + let lease_probe = registry.register(probe).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), a); + drop(lease_probe); + let lease_b = registry.register(b.clone()).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), a); + fs::remove_file(&a).unwrap(); + lease_b.refresh_at(Instant::now() + PROBE_INTERVAL).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), b); + drop(lease_a); + assert_eq!(fs::read_link(&stable).unwrap(), b); + drop(lease_b); + assert!(!stable.exists()); + assert!(fs::symlink_metadata(&stable).is_ok()); + let _lease_b = registry.register(b.clone()).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), b); + assert!(registry.register(stable).is_err()); + drop(_lease_b); + drop(registry); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/remote.rs b/src/remote.rs index a79ac774..39c8bd58 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -4,6 +4,8 @@ mod host; mod process; mod restart_policy; mod saved; +#[cfg(unix)] +mod ssh_agent; pub(crate) use args::*; pub(crate) use attach::*; diff --git a/src/remote/host.rs b/src/remote/host.rs index 08f5d122..825ae10c 100644 --- a/src/remote/host.rs +++ b/src/remote/host.rs @@ -20,6 +20,8 @@ pub(crate) fn run_remote_client_bridge(args: &[String]) -> io::Result<()> { } }; ensure_remote_server_running()?; + #[cfg(unix)] + let _ssh_agent = super::ssh_agent::Registration::start(); let socket_path = crate::server::socket_paths::client_socket_path(); let stream = crate::ipc::connect_local_stream(&socket_path).map_err(|err| { diff --git a/src/remote/ssh_agent.rs b/src/remote/ssh_agent.rs new file mode 100644 index 00000000..639ce8e6 --- /dev/null +++ b/src/remote/ssh_agent.rs @@ -0,0 +1,239 @@ +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::api::client::{parse_response_value, ApiClientError}; +use crate::api::schema::{Method, Request, ResponseResult, ServerSshAgentRegisterParams}; +use crate::ipc::{LocalStream, LocalStreamRead, LocalStreamReadCount}; + +pub(super) struct Registration { + stop: Arc<AtomicBool>, + thread: Option<std::thread::JoinHandle<()>>, +} + +impl Registration { + pub(super) fn start() -> Option<Self> { + let path = std::env::var("SSH_AUTH_SOCK") + .ok() + .filter(|path| !path.is_empty())?; + Self::start_at(path, crate::api::socket_path()) + } + + fn start_at(path: String, socket_path: PathBuf) -> Option<Self> { + let mut stream = match connect(&path, &socket_path) { + Ok(None) => return None, + Ok(stream) => stream, + Err(error) => { + tracing::debug!(%error, "SSH agent refresh unavailable; retrying while attached"); + None + } + }; + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let thread = std::thread::spawn(move || { + let mut byte = [0]; + while !worker_stop.load(Ordering::Relaxed) { + if let Some(connection) = stream.as_mut() { + if !matches!( + crate::ipc::poll_local_stream_read(connection, &mut byte), + Ok(LocalStreamRead::Pending) + ) { + stream = None; + } + } else { + // Retry both initial API readiness and connections lost during handoff. + match connect(&path, &socket_path) { + Ok(None) => break, + result => stream = result.ok().flatten(), + } + } + std::thread::sleep(Duration::from_millis(100)); + } + }); + Some(Self { + stop, + thread: Some(thread), + }) + } +} + +impl Drop for Registration { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn connect(path: &str, socket_path: &Path) -> io::Result<Option<LocalStream>> { + let timeout = Duration::from_millis(500); + let status = crate::api::read_runtime_status_at(socket_path, timeout)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotConnected, + "SSH agent status API is not ready", + ) + })?; + if !status + .capabilities + .is_some_and(|capabilities| capabilities.ssh_agent_registration) + { + return Ok(None); + } + let mut stream = crate::ipc::connect_local_stream(socket_path)?; + let request = Request { + id: "remote:ssh-agent".into(), + method: Method::ServerSshAgentRegister(ServerSshAgentRegisterParams { + socket_path: path.into(), + }), + }; + serde_json::to_writer(&mut stream, &request)?; + stream.write_all(b"\n")?; + crate::ipc::set_local_stream_polling(&mut stream, true)?; + let deadline = Instant::now() + timeout; + let mut response = Vec::new(); + let mut byte = [0]; + while Instant::now() < deadline && response.len() < 4096 { + match crate::ipc::poll_local_stream_read_count(&mut stream, &mut byte)? { + LocalStreamReadCount::Data(_) if byte[0] == b'\n' => { + let response = match parse_response_value(serde_json::from_slice(&response)?) { + Ok(response) => response, + Err(ApiClientError::ErrorResponse(response)) + if response.error.code == "invalid_ssh_agent" => + { + tracing::debug!(error = %response.error.message, "SSH agent registration rejected"); + return Ok(None); + } + Err(error) => return Err(io::Error::other(error)), + }; + return match response.result { + ResponseResult::Ok {} => Ok(Some(stream)), + _ => Err(io::Error::other( + "unexpected SSH agent registration response", + )), + }; + } + LocalStreamReadCount::Data(_) => response.push(byte[0]), + LocalStreamReadCount::Closed => { + return Err(io::Error::other("SSH agent registration closed")) + } + LocalStreamReadCount::Pending => std::thread::sleep(Duration::from_millis(10)), + } + } + Err(io::Error::new( + io::ErrorKind::TimedOut, + "SSH agent registration did not complete", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader, Read}; + use std::os::unix::net::UnixListener; + + #[test] + fn registration_stops_when_the_server_rejects_the_agent() { + let socket_path = + std::env::temp_dir().join(format!("herdr-agent-rejected-{}.sock", std::process::id())); + let listener = UnixListener::bind(&socket_path).unwrap(); + let server = std::thread::spawn(move || { + for expected in ["ping", "server.ssh_agent.register"] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut line = String::new(); + BufReader::new(&mut stream).read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], expected); + let response = if expected == "ping" { + serde_json::json!({"id": request["id"], "result": { + "type": "pong", "version": "test", "protocol": crate::protocol::PROTOCOL_VERSION, + "capabilities": {"live_handoff": false, "ssh_agent_registration": true} + }}) + } else { + serde_json::json!({"id": request["id"], "error": { + "code": "invalid_ssh_agent", "message": "invalid agent path" + }}) + }; + writeln!(stream, "{response}").unwrap(); + } + }); + let registration = + Registration::start_at("relative-agent.sock".into(), socket_path.clone()); + server.join().unwrap(); + std::fs::remove_file(socket_path).unwrap(); + assert!( + registration.is_none(), + "a rejected agent must not start a retry worker" + ); + } + + #[test] + fn registration_retries_when_the_api_is_initially_missing() { + let socket_path = + std::env::temp_dir().join(format!("herdr-agent-retry-{}.sock", std::process::id())); + let registration = Registration::start_at("/test/agent.sock".into(), socket_path.clone()) + .expect("missing API must not permanently disable registration"); + let listener = UnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + for (attempt, expected) in [ + "ping", + "server.ssh_agent.register", + "ping", + "server.ssh_agent.register", + ] + .into_iter() + .enumerate() + { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + break stream; + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "registration did not retry"); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("{error}"), + } + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut line = String::new(); + BufReader::new(&mut stream).read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], expected); + if attempt == 1 { + writeln!(stream, "{}", serde_json::json!({"id": request["id"], "error": { + "code": "ssh_agent_unavailable", "message": "replacement server owns the address" + }})).unwrap(); + continue; + } + let result = if expected == "ping" { + serde_json::json!({"type": "pong", "version": "test", "protocol": crate::protocol::PROTOCOL_VERSION, + "capabilities": {"live_handoff": false, "ssh_agent_registration": true}}) + } else { + serde_json::json!({"type": "ok"}) + }; + writeln!( + stream, + "{}", + serde_json::json!({"id": request["id"], "result": result}) + ) + .unwrap(); + if expected == "server.ssh_agent.register" { + drop(registration); + assert_eq!(stream.read(&mut [0]).unwrap(), 0); + break; + } + } + std::fs::remove_file(socket_path).unwrap(); + } +} diff --git a/src/update.rs b/src/update.rs index 4c128c57..958be6e1 100644 --- a/src/update.rs +++ b/src/update.rs @@ -2853,6 +2853,7 @@ mod tests { ), surface_interest: true, health_check: true, + ssh_agent_registration: false, }), }; let missing_baseline = crate::api::RuntimeStatus { @@ -2927,6 +2928,7 @@ mod tests { ), surface_interest: true, health_check: true, + ssh_agent_registration: false, }), }, }; @@ -3185,6 +3187,7 @@ mod tests { ), surface_interest: true, health_check: true, + ssh_agent_registration: false, }), }, }; From 309749ad65f3aa596f077ec23a1bf3ee428b7a04 Mon Sep 17 00:00:00 2001 From: Ainesh Chatterjee <ainesh@factory.ai> Date: Mon, 21 Sep 2026 06:38:56 -0700 Subject: [PATCH 39/99] fix: honor droid scrollback clear requests in panes (#4432) Remove the droid-specific filter that discarded ED3 before PTY output reached the terminal parser. Keeping stale scrollback beneath replayed primary-screen frames duplicated the welcome header and old transcript. Pass the original bytes to the parser and trackers. Add process-aware regression coverage across PTY chunk boundaries and preserve ED2/ED3 screen-history separation and alternate-screen isolation. Co-authored-by: ain3sh <ainesh.chatterjee@gmail.com> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- src/pane/osc.rs | 138 --------------------------- src/pane/terminal.rs | 43 ++------- src/pane/terminal/migration_tests.rs | 128 ++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 174 deletions(-) diff --git a/src/pane/osc.rs b/src/pane/osc.rs index a281af77..472fa7aa 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::path::PathBuf; use tracing::info; @@ -695,68 +694,6 @@ pub(super) fn current_transient_default_color_owner(shell_pid: u32) -> Option<u3 (!foreground_job_is_shell(&job, shell_pid)).then_some(job.process_group_id) } -fn foreground_job_uses_droid_scrollback_compat(job: &crate::platform::ForegroundJob) -> bool { - job.processes.iter().any(|process| { - process.name.eq_ignore_ascii_case("droid") - || process - .argv0 - .as_deref() - .is_some_and(|argv0| argv0.eq_ignore_ascii_case("droid")) - || process.cmdline.as_deref().is_some_and(|cmdline| { - cmdline.eq_ignore_ascii_case("droid") - || cmdline.starts_with("droid ") - || cmdline.to_ascii_lowercase().contains("/droid") - }) - }) -} - -pub(super) fn contains_scrollback_clear_sequence(bytes: &[u8]) -> bool { - bytes.windows(4).any(|window| window == b"\x1b[3J") - || bytes.windows(5).any(|window| window == b"\x1b[?3J") -} - -fn strip_scrollback_clear_sequences<'a>(bytes: &'a [u8]) -> Cow<'a, [u8]> { - if !contains_scrollback_clear_sequence(bytes) { - return Cow::Borrowed(bytes); - } - - let mut filtered = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - let remaining = &bytes[index..]; - if remaining.starts_with(b"\x1b[3J") { - index += 4; - continue; - } - if remaining.starts_with(b"\x1b[?3J") { - index += 5; - continue; - } - filtered.push(bytes[index]); - index += 1; - } - - Cow::Owned(filtered) -} - -pub(super) fn maybe_filter_primary_screen_scrollback_clear<'a>( - bytes: &'a [u8], - alternate_screen: bool, - foreground_job: Option<&crate::platform::ForegroundJob>, -) -> Cow<'a, [u8]> { - // Droid redraws its primary-screen TUI with CSI 3 J, which erases pane - // scrollback inside herdr. Keep the hack scoped to Droid on the primary - // screen so normal terminal clear-history behavior still works elsewhere. - if alternate_screen - || !contains_scrollback_clear_sequence(bytes) - || !foreground_job.is_some_and(foreground_job_uses_droid_scrollback_compat) - { - return Cow::Borrowed(bytes); - } - - strip_scrollback_clear_sequences(bytes) -} - #[cfg(target_os = "macos")] pub(super) fn should_restore_host_terminal_theme( owner_pgid: u32, @@ -1350,81 +1287,6 @@ mod tests { ); } - #[test] - fn droid_scrollback_compat_matches_process_name_and_cmdline() { - let name_only = crate::platform::ForegroundJob { - process_group_id: 42, - processes: vec![crate::platform::ForegroundProcess { - pid: 42, - name: "droid".to_string(), - argv0: None, - argv: Some(vec![ - "/opt/factory/droid".to_string(), - "--resume".to_string(), - ]), - cmdline: Some("/opt/factory/droid --resume".to_string()), - }], - }; - assert!(foreground_job_uses_droid_scrollback_compat(&name_only)); - - let cmdline_only = crate::platform::ForegroundJob { - process_group_id: 42, - processes: vec![crate::platform::ForegroundProcess { - pid: 42, - name: "bun".to_string(), - argv0: Some("bun".to_string()), - argv: Some(vec![ - "bun".to_string(), - "/home/can/.local/bin/droid".to_string(), - "--resume".to_string(), - ]), - cmdline: Some("/home/can/.local/bin/droid --resume".to_string()), - }], - }; - assert!(foreground_job_uses_droid_scrollback_compat(&cmdline_only)); - - let shell = shell_job(7); - assert!(!foreground_job_uses_droid_scrollback_compat(&shell)); - } - - #[test] - fn strip_scrollback_clear_sequences_removes_ed3_only() { - let filtered = strip_scrollback_clear_sequences(b"a\x1b[3Jb\x1b[?3Jc\x1b[2Jd"); - assert_eq!(filtered.as_ref(), b"abc\x1b[2Jd"); - } - - #[test] - fn primary_screen_droid_compat_ignores_scrollback_clear_only_for_droid() { - let droid_job = crate::platform::ForegroundJob { - process_group_id: 42, - processes: vec![crate::platform::ForegroundProcess { - pid: 42, - name: "droid".to_string(), - argv0: Some("droid".to_string()), - argv: Some(vec!["droid".to_string()]), - cmdline: Some("droid".to_string()), - }], - }; - - let filtered = maybe_filter_primary_screen_scrollback_clear( - b"\x1b[3J\x1b[2J", - false, - Some(&droid_job), - ); - assert_eq!(filtered.as_ref(), b"\x1b[2J"); - - let shell = maybe_filter_primary_screen_scrollback_clear( - b"\x1b[3J\x1b[2J", - false, - Some(&shell_job(7)), - ); - assert_eq!(shell.as_ref(), b"\x1b[3J\x1b[2J"); - - let alternate = - maybe_filter_primary_screen_scrollback_clear(b"\x1b[3J\x1b[2J", true, Some(&droid_job)); - assert_eq!(alternate.as_ref(), b"\x1b[3J\x1b[2J"); - } - #[test] fn host_theme_restore_waits_for_shell_and_non_alternate_screen() { assert!(!should_restore_host_terminal_theme( diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index c30ef415..c9d0b90a 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; @@ -33,8 +32,7 @@ use super::{ }, kitty_keyboard::KittyKeyboardTracker, osc::{ - contains_scrollback_clear_sequence, current_transient_default_color_owner, - maybe_filter_primary_screen_scrollback_clear, parse_reported_cwd, + current_transient_default_color_owner, parse_reported_cwd, restore_host_terminal_theme_if_needed, write_host_terminal_theme_selective, AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, OscDebugTracker, @@ -1381,43 +1379,18 @@ impl GhosttyPaneTerminal { } let terminal_title_changed = core.agent_osc_state.observe(bytes); - let alternate_screen = core - .terminal - .active_screen() - .map(|screen| screen == crate::ghostty::ActiveScreen::Alternate) - .unwrap_or(false); - let filtered_bytes = if shell_pid > 0 { - let foreground_job = (!alternate_screen && contains_scrollback_clear_sequence(bytes)) - .then(|| crate::detect::foreground_job(shell_pid)) - .flatten(); - maybe_filter_primary_screen_scrollback_clear( - bytes, - alternate_screen, - foreground_job.as_ref(), - ) - } else { - Cow::Borrowed(bytes) - }; - if filtered_bytes.len() != bytes.len() { - debug!( - pane = pane_id.raw(), - shell_pid, "ignored scrollback clear sequence for droid compatibility" - ); - } - - core.kitty_keyboard.observe(filtered_bytes.as_ref()); + core.kitty_keyboard.observe(bytes); let mut terminal_responses = Vec::new(); - core.default_color_event_tracker - .observe(filtered_bytes.as_ref()); - core.c1_xtgettcap_tracker.observe(filtered_bytes.as_ref()); + core.default_color_event_tracker.observe(bytes); + core.c1_xtgettcap_tracker.observe(bytes); let c1_xtgettcap_responses = core.c1_xtgettcap_tracker.drain_pending(); - core.decscusr_tracker.observe(filtered_bytes.as_ref()); + core.decscusr_tracker.observe(bytes); let in_progress_default_color_event = core.default_color_event_tracker.in_progress_event(); let default_color_events = core.default_color_event_tracker.drain_pending(); let write_started = crate::render_prof::timer(); self.write_pty_bytes_with_ordered_responses( &mut core, - filtered_bytes.as_ref(), + bytes, default_color_events, in_progress_default_color_event, c1_xtgettcap_responses, @@ -1435,8 +1408,8 @@ impl GhosttyPaneTerminal { windows_recent_fallback::update_after_write(&mut core); crate::render_prof::duration_since("pty.ghostty_write", write_started); - let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled() - && contains_kitty_graphics_sequence(filtered_bytes.as_ref()); + let has_kitty_graphics_sequence = + crate::kitty_graphics::is_enabled() && contains_kitty_graphics_sequence(bytes); if has_kitty_graphics_sequence { debug!(pane = pane_id.raw(), "processed kitty graphics sequence"); } diff --git a/src/pane/terminal/migration_tests.rs b/src/pane/terminal/migration_tests.rs index 94d78a77..09e5df01 100644 --- a/src/pane/terminal/migration_tests.rs +++ b/src/pane/terminal/migration_tests.rs @@ -46,9 +46,13 @@ impl Harness { } fn write(&mut self, bytes: &[u8]) { + self.write_from_process(0, bytes); + } + + fn write_from_process(&mut self, shell_pid: u32, bytes: &[u8]) { let result = self .pane - .process_pty_bytes(PaneId::from_raw(1), 0, bytes, &self.tx); + .process_pty_bytes(PaneId::from_raw(1), shell_pid, bytes, &self.tx); for reply in result.terminal_responses { self.effects.replies.extend_from_slice(&reply); } @@ -111,6 +115,128 @@ impl Harness { } } +#[cfg(target_os = "linux")] +#[test] +fn primary_screen_replay_honors_ed3_for_droid_at_chunk_boundaries() { + // A zero PID would bypass the former process-specific filter entirely. + // Use the real foreground-job lookup, with cleanup even on assertion failure. + struct ChildGuard(Box<dyn portable_pty::Child + Send + Sync>); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let pair = portable_pty::native_pty_system() + .openpty(portable_pty::PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut command = portable_pty::CommandBuilder::new("bash"); + command.args(["-c", "exec -a droid sleep 999"]); + let child = ChildGuard(pair.slave.spawn_command(command).unwrap()); + let pid = child.0.process_id().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let ready = crate::detect::foreground_job(pid).is_some_and(|job| { + job.processes + .iter() + .any(|process| process.cmdline.as_deref() == Some("droid 999")) + }); + if ready { + break; + } + assert!(Instant::now() < deadline, "Droid foreground job not ready"); + std::thread::sleep(Duration::from_millis(10)); + } + + for clear in ["\x1b[3J", "\x1b[?3J"] { + let prefix = format!("\x1b[?2026h\x1b[2J{clear}\x1b[H"); + let frame = |label: &str| { + let mut bytes = format!("{prefix}welcome\r\n"); + for row in 0..55 { + bytes.push_str(&format!("{label}-{row:02}\r\n")); + } + bytes.push_str("\x1b[?2026l"); + bytes + }; + let old = frame("old"); + let new = frame("new"); + // Whole writes catch the old filter; bytewise writes and every split + // within the erase prefix prove that PTY chunking cannot change ED3. + for chunk_size in [usize::MAX, 1, 7] { + for split in 0..=prefix.len() { + let mut harness = Harness::new(80, 24); + for bytes in old.as_bytes().chunks(chunk_size) { + harness.write_from_process(pid, bytes); + } + assert!(harness + .pane + .recent_text_snapshot(256) + .text + .contains("old-00")); + harness.write_from_process(pid, &new.as_bytes()[..split]); + for bytes in new.as_bytes()[split..].chunks(chunk_size) { + harness.write_from_process(pid, bytes); + } + let recent = harness.pane.recent_text_snapshot(256).text; + assert!(recent.contains("new-54"), "redraw must complete"); + assert_eq!( + recent.matches("welcome").count(), + 1, + "{clear:?}, split {split}, chunk {chunk_size}: {recent}" + ); + assert!(!recent.contains("old-"), "{recent}"); + assert!(harness.pane.visible_text().contains("new-54")); + harness.pane.scroll_up(256); + let top = harness.pane.visible_text(); + assert!(top.contains("welcome") && top.contains("new-00"), "{top}"); + assert!(!top.contains("old-"), "{top}"); + } + } + } +} + +#[test] +fn erase_display_preserves_screen_and_history_boundaries() { + for clear in [b"\x1b[3J".as_slice(), b"\x1b[?3J"] { + let mut harness = Harness::new(80, 24); + for row in 0..55 { + harness.write(format!("history-{row:02}\r\n").as_bytes()); + } + assert!(harness + .pane + .recent_text_snapshot(256) + .text + .contains("history-00")); + let primary = harness.pane.recent_text_snapshot(256); + let visible = harness.pane.visible_text(); + + harness.write(b"\x1b[?1049h\x1b[2J\x1b[Halternate"); + harness.write(clear); + assert!(harness.pane.visible_text().contains("alternate")); + harness.write(b"\x1b[?1049l"); + assert_eq!(harness.pane.recent_text_snapshot(256), primary); + assert_eq!(harness.pane.visible_text(), visible); + + // ED2 clears only the display, not prior shell output in scrollback. + harness.write(b"\x1b[2J\x1b[Hprompt"); + assert_eq!(harness.pane.visible_text().trim(), "prompt"); + assert!(harness + .pane + .recent_text_snapshot(256) + .text + .contains("history-00")); + harness.write(clear); + // ED3 clears history without erasing the current display. + assert_eq!(harness.pane.visible_text().trim(), "prompt"); + assert_eq!(harness.pane.recent_text_snapshot(256).text.trim(), "prompt"); + } +} + #[test] fn short_streams_are_invariant_at_every_byte_boundary() { let fixtures: &[&[u8]] = &[ From 28360107c1bfab57fb0de99d74bfa48e74423ca8 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Mon, 21 Sep 2026 20:47:14 +0300 Subject: [PATCH 40/99] fix: distinguish agent completion from startup and session changes (#4457) refs #3338 refs #3990 refs #3916 --- docs/next/api/herdr-api.schema.json | 9 + .../src/content/docs/agent-automation.mdx | 2 + src/api/schema/agents.rs | 3 + src/app/actions.rs | 364 +++++++++++------ src/app/agents.rs | 1 + src/client/endpoint/control.rs | 30 ++ src/client/mod.rs | 10 + src/client/shell/endpoint_agent_state.rs | 235 ++++++++++- src/client/shell/endpoints.rs | 19 +- src/protocol/endpoint.rs | 20 + src/server/client_shell.rs | 28 +- src/server/clients.rs | 2 + src/server/headless.rs | 49 ++- src/server/headless/notifications.rs | 80 +--- src/server/headless/render.rs | 19 +- src/server/headless/tests/mod.rs | 376 +++++++++++++++--- src/server/headless/tests/pane_graphics.rs | 25 +- src/server/headless/tests/pane_move.rs | 4 +- src/server/headless/tests/surface_interest.rs | 23 +- src/server/notifications.rs | 6 +- src/server/render_scale_benchmark.rs | 29 +- src/terminal/state.rs | 44 +- 22 files changed, 1061 insertions(+), 317 deletions(-) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index bd758e9e..b4648b03 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -6693,6 +6693,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", diff --git a/docs/next/website/src/content/docs/agent-automation.mdx b/docs/next/website/src/content/docs/agent-automation.mdx index e7c02ae3..def306fa 100644 --- a/docs/next/website/src/content/docs/agent-automation.mdx +++ b/docs/next/website/src/content/docs/agent-automation.mdx @@ -77,6 +77,8 @@ Pane input addresses the terminal regardless of its current occupant. Agent inpu `idle` and `done` both mean the agent is ready for input. The CLI/API uses the server's seen state: `done` is idle but not yet marked seen, explicit `pane focus` / `agent focus` commands mark the target seen, and reads do not. Each TUI client tracks viewed completions independently, so a client's Done badge can differ from the CLI or another client's badge. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify its lifecycle confidently; it does not prove successful completion. Use exact `--until` states when that distinction matters. +Startup readiness, restoring an idle conversation, and switching agent conversations do not count as completed work. An agent that starts working immediately can finish its first task without Herdr first seeing an idle prompt. Agent responses may include `completion_seq`, identifying the current idle transition as completed work independently of who has viewed it. It matches that transition's `state_change_seq`; startup and session changes do not set it. Older servers may omit this field. Clients connected to older servers use observed working states to guard Done badges, so they may miss a turn that starts and finishes between updates. + A timeout or `agent_prompt_stalled` does not prove that no input was sent. Read the agent before retrying to avoid submitting the same prompt twice. IDs and agent names are scoped to one server; selecting another machine in the TUI does not retarget CLI commands running in an existing pane. `pane wait-output` does not interpret agent lifecycle. It polls the selected terminal snapshot and searches it immediately, so text that was already present can match. The default source name is `recent`; matching treats that source as unwrapped recent output from the latest 80 rendered terminal rows. `--lines` changes that row limit, and `--regex` uses Rust regular-expression syntax and matches one line at a time. 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<u64>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/app/actions.rs b/src/app/actions.rs index 85314331..953bcd94 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<String> { @@ -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<bool>, @@ -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<crate::sound::Sound> { - 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<crate::sound::Sound> { - 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<ToastKind> { 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<ToastKind> { - 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(), ) } @@ -1709,39 +1640,55 @@ 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 had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen; let mutation = update(terminal)?; + 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)?; @@ -3054,34 +3001,217 @@ 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), - state: AgentState::Unknown, - visible_blocker: false, - visible_working: false, - process_exited: false, - observed_at: std::time::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(), - }); + #[test] + fn completion_guard_first_work_finishes_without_prior_idle() { + assert_completion_guard_sequence(true, &[AgentState::Working, AgentState::Idle], true); + } - let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); - assert!(!pane.seen); + #[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 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 +3269,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) )); 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/client/endpoint/control.rs b/src/client/endpoint/control.rs index 6eef8dc8..f2fe1b76 100644 --- a/src/client/endpoint/control.rs +++ b/src/client/endpoint/control.rs @@ -9,6 +9,7 @@ pub(crate) struct DecodedAgentViewProjection { pub(crate) enum EndpointControlMessage { HealthPong, AgentViewProjection(DecodedAgentViewProjection), + AgentCompletions(crate::protocol::endpoint::EndpointAgentCompletions), Snapshot(Box<crate::protocol::ClientShellSnapshot>), Ignored, } @@ -20,6 +21,11 @@ pub(crate) fn decode_endpoint_control( if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { return Ok(EndpointControlMessage::HealthPong); } + if kind == crate::protocol::endpoint::AGENT_COMPLETIONS_KIND { + return Ok(serde_json::from_str(data) + .map(EndpointControlMessage::AgentCompletions) + .unwrap_or(EndpointControlMessage::Ignored)); + } if kind == crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND { let Ok(projection): Result<crate::protocol::endpoint::EndpointAgentViewProjection, _> = serde_json::from_str(data) @@ -77,6 +83,30 @@ mod tests { )); } + #[test] + fn completion_guard_optional_control_round_trips_without_changing_snapshot_codec() { + let projection = crate::protocol::endpoint::EndpointAgentCompletions { + boot_id: "boot".into(), + revision: 3, + completions: [("pane".into(), 7)].into_iter().collect(), + }; + let crate::protocol::ServerMessage::EndpointControl { kind, data } = + crate::protocol::endpoint::agent_completions_message(&projection).unwrap() + else { + panic!("expected optional control"); + }; + let EndpointControlMessage::AgentCompletions(decoded) = + decode_endpoint_control(&kind, &data).unwrap() + else { + panic!("expected completion projection"); + }; + assert_eq!(decoded, projection); + assert!(matches!( + decode_endpoint_control(&kind, "invalid").unwrap(), + EndpointControlMessage::Ignored + )); + } + #[test] fn agent_view_projection_decodes_and_validates_the_view() { let view = crate::api::schema::AgentViewSetParams { diff --git a/src/client/mod.rs b/src/client/mod.rs index b9335e06..68e569f9 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1913,6 +1913,16 @@ async fn run_client_loop( } continue; } + Ok(endpoint::EndpointControlMessage::AgentCompletions(projection)) => { + if let Some(shell) = state.shell.as_mut() { + shell.set_endpoint_agent_completions( + &endpoint_id, + generation, + projection, + ); + } + continue; + } Ok(endpoint::EndpointControlMessage::Ignored) => { debug!(%kind, "ignoring unknown endpoint control message"); continue; diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs index f8ba7a9b..1ebcd35d 100644 --- a/src/client/shell/endpoint_agent_state.rs +++ b/src/client/shell/endpoint_agent_state.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::api::schema::AgentStatus; use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame}; @@ -7,13 +7,49 @@ use crate::protocol::{ClientShellAgent, ClientShellSnapshot, PaneSurfaceFrame}; pub(super) struct EndpointAgentPresentation { boot_id: Option<String>, acknowledged: HashMap<String, u64>, + completed: HashMap<String, u64>, + working: HashSet<String>, + pending_completions: Option<( + Option<u64>, + crate::protocol::endpoint::EndpointAgentCompletions, + )>, } impl EndpointAgentPresentation { + pub(super) fn receive_completions( + &mut self, + generation: Option<u64>, + completions: crate::protocol::endpoint::EndpointAgentCompletions, + ) { + if self + .pending_completions + .as_ref() + .is_some_and(|(current_generation, current)| { + *current_generation == generation + && current.boot_id == completions.boot_id + && current.revision >= completions.revision + }) + { + return; + } + self.pending_completions = Some((generation, completions)); + } + + #[cfg(test)] pub(super) fn project_snapshot(&mut self, snapshot: &mut ClientShellSnapshot) { + self.project_snapshot_for_generation(snapshot, None); + } + + pub(super) fn project_snapshot_for_generation( + &mut self, + snapshot: &mut ClientShellSnapshot, + generation: Option<u64>, + ) { if self.boot_id.as_deref() != Some(snapshot.boot_id.as_str()) { self.boot_id = Some(snapshot.boot_id.clone()); self.acknowledged.clear(); + self.completed.clear(); + self.working.clear(); self.acknowledged.extend( snapshot .agents @@ -21,13 +57,59 @@ impl EndpointAgentPresentation { .map(|agent| (agent.pane_id.clone(), agent.state_change_seq)), ); } - self.acknowledged.retain(|pane_id, _| { - snapshot - .agents - .iter() - .any(|agent| &agent.pane_id == pane_id) - }); + let pane_ids: HashSet<&str> = snapshot + .agents + .iter() + .map(|agent| agent.pane_id.as_str()) + .collect(); + self.acknowledged + .retain(|pane_id, _| pane_ids.contains(pane_id.as_str())); + self.completed + .retain(|pane_id, _| pane_ids.contains(pane_id.as_str())); + self.working + .retain(|pane_id| pane_ids.contains(pane_id.as_str())); + let completions = self + .pending_completions + .take() + .filter(|(received_generation, projection)| { + *received_generation == generation + && projection.boot_id == snapshot.boot_id + && projection.revision == snapshot.revision + }) + .map(|(_, projection)| projection.completions); for agent in &mut snapshot.agents { + match agent.agent_status { + AgentStatus::Working => { + self.working.insert(agent.pane_id.clone()); + self.completed.remove(&agent.pane_id); + } + AgentStatus::Blocked => { + self.completed.remove(&agent.pane_id); + } + AgentStatus::Idle | AgentStatus::Done => { + let observed_work = self.working.remove(&agent.pane_id); + let completed = completions.as_ref().map_or_else( + || { + observed_work + || self.completed.get(&agent.pane_id) + == Some(&agent.state_change_seq) + }, + |completions| { + completions.get(&agent.pane_id) == Some(&agent.state_change_seq) + }, + ); + if completed { + self.completed + .insert(agent.pane_id.clone(), agent.state_change_seq); + } else { + self.completed.remove(&agent.pane_id); + } + } + _ => { + self.working.remove(&agent.pane_id); + self.completed.remove(&agent.pane_id); + } + } agent.agent_status = self.projected_status(agent); } project_aggregate_status(snapshot); @@ -72,9 +154,11 @@ impl EndpointAgentPresentation { } pub(super) fn seen(&self, agent: &ClientShellAgent) -> bool { - self.acknowledged - .get(&agent.pane_id) - .is_some_and(|sequence| *sequence >= agent.state_change_seq) + self.completed.get(&agent.pane_id).is_none_or(|completion| { + self.acknowledged + .get(&agent.pane_id) + .is_some_and(|sequence| sequence >= completion) + }) } fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { @@ -119,7 +203,9 @@ fn project_aggregate_status(snapshot: &mut ClientShellSnapshot) { #[cfg(test)] mod tests { use super::*; - use crate::protocol::{FrameData, PaneSurfacePane, SurfaceRect}; + use crate::protocol::{ + endpoint::EndpointAgentCompletions, FrameData, PaneSurfacePane, SurfaceRect, + }; fn agent(status: AgentStatus, sequence: u64) -> ClientShellAgent { ClientShellAgent { @@ -197,6 +283,133 @@ mod tests { assert_eq!(snapshot.agents[0].agent_status, AgentStatus::Idle); } + fn assert_idle_sequence(states: &[(AgentStatus, u64)]) { + let mut presentation = EndpointAgentPresentation::default(); + let mut projected = AgentStatus::Unknown; + for (revision, &(status, seq)) in states.iter().enumerate() { + let mut snapshot = snapshot(status, seq, revision as u64 + 1); + presentation.project_snapshot(&mut snapshot); + projected = snapshot.agents[0].agent_status; + } + assert_eq!(projected, AgentStatus::Idle, "{states:?}"); + } + + fn completions(boot: &str, revision: u64, seq: Option<u64>) -> EndpointAgentCompletions { + EndpointAgentCompletions { + boot_id: boot.into(), + revision, + completions: seq + .map(|seq| ("agent-pane".into(), seq)) + .into_iter() + .collect(), + } + } + + #[test] + fn completion_guard_initial_unknown_to_idle_does_not_project_done() { + assert_idle_sequence(&[(AgentStatus::Unknown, 0), (AgentStatus::Idle, 1)]); + } + + #[test] + fn completion_guard_new_idle_agent_is_not_completed_work() { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Idle, 0, 1); + baseline.agents.clear(); + presentation.project_snapshot(&mut baseline); + let mut ready = snapshot(AgentStatus::Idle, 1, 2); + + presentation.project_snapshot(&mut ready); + + assert_eq!(ready.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn completion_guard_session_rebind_does_not_project_done() { + assert_idle_sequence(&[ + (AgentStatus::Idle, 4), + (AgentStatus::Unknown, 5), + (AgentStatus::Idle, 6), + ]); + } + + #[test] + fn completion_guard_startup_blocker_does_not_project_done() { + assert_idle_sequence(&[ + (AgentStatus::Unknown, 0), + (AgentStatus::Blocked, 1), + (AgentStatus::Idle, 2), + ]); + } + + #[test] + fn completion_guard_distinguishes_coalesced_work_from_rebind() { + for completion in [None, Some(6)] { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Idle, 4, 1); + presentation.project_snapshot(&mut baseline); + presentation.receive_completions(None, completions(&baseline.boot_id, 2, completion)); + let mut settled = snapshot(AgentStatus::Idle, 6, 2); + + presentation.project_snapshot(&mut settled); + + let expected = if completion.is_some() { + AgentStatus::Done + } else { + AgentStatus::Idle + }; + assert_eq!(settled.agents[0].agent_status, expected); + presentation.project_snapshot(&mut settled); + assert_eq!(settled.agents[0].agent_status, expected); + } + } + + #[test] + fn completion_guard_server_suppression_overrides_client_observed_work() { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Working, 1, 1); + presentation.project_snapshot(&mut baseline); + presentation.receive_completions(None, completions(&baseline.boot_id, 2, None)); + let mut settled = snapshot(AgentStatus::Idle, 2, 2); + + presentation.project_snapshot(&mut settled); + + assert_eq!(settled.agents[0].agent_status, AgentStatus::Idle); + } + + #[test] + fn completion_guard_companion_is_scoped_to_boot_revision_and_connection() { + for (boot, revision, generation, seq) in [ + ("old-boot", 2, Some(1), 6), + ("endpoint-boot", 1, Some(1), 6), + ("endpoint-boot", 2, Some(0), 6), + ("endpoint-boot", 2, Some(1), 5), + ] { + let mut presentation = EndpointAgentPresentation::default(); + let mut baseline = snapshot(AgentStatus::Idle, 4, 1); + presentation.project_snapshot_for_generation(&mut baseline, Some(1)); + presentation.receive_completions(generation, completions(boot, revision, Some(seq))); + let mut settled = snapshot(AgentStatus::Idle, 6, 2); + + presentation.project_snapshot_for_generation(&mut settled, Some(1)); + + assert_eq!(settled.agents[0].agent_status, AgentStatus::Idle); + } + } + + #[test] + fn completion_guard_new_boot_baselines_existing_completions() { + let mut presentation = EndpointAgentPresentation::default(); + let mut old = snapshot(AgentStatus::Working, 1, 1); + old.boot_id = "old-boot".into(); + presentation.project_snapshot(&mut old); + presentation.receive_completions(None, completions("endpoint-boot", 1, Some(6))); + let mut restored = snapshot(AgentStatus::Idle, 6, 1); + + presentation.project_snapshot(&mut restored); + + assert_eq!(restored.agents[0].agent_status, AgentStatus::Idle); + } + #[test] fn unpresented_working_completion_projects_done() { let mut presentation = EndpointAgentPresentation::default(); diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 513842ba..dd6fa3cb 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -309,6 +309,23 @@ impl ClientShellState { .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision)) } + pub(crate) fn set_endpoint_agent_completions( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + projection: crate::protocol::endpoint::EndpointAgentCompletions, + ) { + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint + .agent_presentation + .receive_completions(Some(generation), projection); + } + } + pub(crate) fn set_endpoint_agent_view_projection_for_generation( &mut self, endpoint_id: &ClientEndpointId, @@ -546,7 +563,7 @@ impl ClientShellState { } self.endpoints[index] .agent_presentation - .project_snapshot(&mut snapshot); + .project_snapshot_for_generation(&mut snapshot, generation); let presented_surface = if acknowledge_surface && endpoint_id == &self.active_endpoint_id { self.pane_surface.as_ref() } else { diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index df83b242..2704bf65 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -29,6 +29,15 @@ pub const HEALTH_PING_KIND: &str = "endpoint.health.ping.v1"; pub const HEALTH_PONG_KIND: &str = "endpoint.health.pong.v1"; pub const AGENT_VIEW_PROJECTION_CAPABILITY: &str = "agent_view_projection"; pub const AGENT_VIEW_PROJECTION_KIND: &str = "endpoint.agent-view.v1"; +pub const AGENT_COMPLETIONS_CAPABILITY: &str = "agent_completions"; +pub const AGENT_COMPLETIONS_KIND: &str = "endpoint.agent-completions.v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointAgentCompletions { + pub boot_id: String, + pub revision: u64, + pub completions: std::collections::BTreeMap<String, u64>, +} fn default_true() -> bool { true @@ -101,6 +110,15 @@ pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result<Se }) } +pub fn agent_completions_message( + projection: &EndpointAgentCompletions, +) -> serde_json::Result<ServerMessage> { + Ok(ServerMessage::EndpointControl { + kind: AGENT_COMPLETIONS_KIND.into(), + data: serde_json::to_string(projection)?, + }) +} + pub fn agent_view_projection_message( boot_id: &str, revision: u64, @@ -151,6 +169,7 @@ impl EndpointServerWelcome { PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), HEALTH_CHECK_CAPABILITY.into(), AGENT_VIEW_PROJECTION_CAPABILITY.into(), + AGENT_COMPLETIONS_CAPABILITY.into(), ], error: None, } @@ -357,6 +376,7 @@ mod tests { PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), HEALTH_CHECK_CAPABILITY.to_string(), AGENT_VIEW_PROJECTION_CAPABILITY.to_string(), + AGENT_COMPLETIONS_CAPABILITY.to_string(), ] ); } diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 37470693..554e8167 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -3,6 +3,7 @@ use ratatui::layout::Rect; use crate::app; use crate::protocol::{self, FrameData}; +#[cfg(test)] pub(super) fn snapshot( app: &app::App, boot_id: &str, @@ -10,7 +11,29 @@ pub(super) fn snapshot( config_diagnostic: Option<&str>, location: Option<&crate::server::clients::ClientShellLocation>, ) -> protocol::ClientShellSnapshot { + snapshot_with_completions(app, boot_id, revision, config_diagnostic, location).0 +} + +pub(super) fn snapshot_with_completions( + app: &app::App, + boot_id: &str, + revision: u64, + config_diagnostic: Option<&str>, + location: Option<&crate::server::clients::ClientShellLocation>, +) -> ( + protocol::ClientShellSnapshot, + protocol::endpoint::EndpointAgentCompletions, +) { let snapshot = app.session_snapshot(); + let completions = protocol::endpoint::EndpointAgentCompletions { + boot_id: boot_id.to_owned(), + revision, + completions: snapshot + .agents + .iter() + .filter_map(|agent| agent.completion_seq.map(|seq| (agent.pane_id.clone(), seq))) + .collect(), + }; let focused_workspace_id = location .and_then(|location| location.focused_workspace_id.clone()) .or_else(|| snapshot.focused_workspace_id.clone()); @@ -216,7 +239,7 @@ pub(super) fn snapshot( preview: notes.preview, }); - protocol::ClientShellSnapshot { + let shell = protocol::ClientShellSnapshot { boot_id: boot_id.to_owned(), revision, config_diagnostic: config_diagnostic.map(str::to_owned), @@ -240,7 +263,8 @@ pub(super) fn snapshot( panes, agents, commands: app.client_shell_command_manifest(), - } + }; + (shell, completions) } pub(super) struct RenderedPaneSurface { diff --git a/src/server/clients.rs b/src/server/clients.rs index aeda3822..65e875b8 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -177,6 +177,7 @@ pub(crate) struct ClientConnection { pub(crate) shell_location: Option<ClientShellLocation>, /// Last coherent shell replacement sent to this client. pub(crate) shell_snapshot: Option<crate::protocol::ClientShellSnapshot>, + pub(crate) shell_agent_completions: Option<crate::protocol::endpoint::EndpointAgentCompletions>, /// View policy paired with the last coherent shell replacement. pub(crate) shell_agent_view: Option<crate::api::schema::AgentViewSetParams>, /// Monotonic shell replacement revision for this connection. @@ -246,6 +247,7 @@ impl ClientConnection { staged_clipboard_files: Vec::new(), shell_location: None, shell_snapshot: None, + shell_agent_completions: None, shell_agent_view: None, shell_projection_revision: 0, shell_endpoint_command_in_flight: false, diff --git a/src/server/headless.rs b/src/server/headless.rs index 638559ad..c5a644ce 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -51,7 +51,8 @@ use crate::server::client_accept::{ accept_pending_client_connections, reject_pending_client_connections, }; use crate::server::client_shell::{ - render_pane_surface as render_client_shell_pane_surface, snapshot as client_shell_snapshot, + render_pane_surface as render_client_shell_pane_surface, + snapshot_with_completions as client_shell_snapshot, }; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ @@ -2041,7 +2042,7 @@ impl HeadlessServer { } else { self.server_config_diagnostic_without_keybindings.as_deref() }; - let seed_snapshot = client_shell_snapshot( + let (seed_snapshot, completion_projection) = client_shell_snapshot( &self.app, &self.client_shell_boot_id, connection.shell_projection_revision, @@ -2073,8 +2074,18 @@ impl HeadlessServer { return false; } }; + let completion_message = match crate::protocol::endpoint::agent_completions_message( + &completion_projection, + ) { + Ok(message) => message, + Err(err) => { + warn!(client_id, err = %err, "failed to encode agent completions"); + return false; + } + }; connection.shell_location = Some(location); connection.shell_snapshot = Some(seed_snapshot); + connection.shell_agent_completions = Some(completion_projection); connection.shell_agent_view = agent_view; self.clients.insert(client_id, connection); if self.app.state.popup_pane.is_some() && self.popup_owner_tab_id.is_none() { @@ -2083,6 +2094,7 @@ impl HeadlessServer { if let Some(message) = projection_message { self.send_to_client(client_id, message); } + self.send_to_client(client_id, completion_message); self.send_to_client(client_id, snapshot_message); if surface_active { self.foreground_client_id = Some(client_id); @@ -3241,7 +3253,10 @@ impl HeadlessServer { }; let new_state = terminal_after.state; - if new_state == *prev_state { + if new_state == *prev_state + || (new_state == crate::detect::AgentState::Idle + && terminal_after.last_agent_completion_seq.is_none()) + { continue; } @@ -3274,15 +3289,11 @@ impl HeadlessServer { && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - if let Some(kind) = - crate::app::actions::notification_toast_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - *prev_state, - new_state, - prev_agent_label.as_deref(), - agent_label.as_deref(), - ) - { + if let Some(kind) = crate::app::actions::notification_toast_for_state_change( + suppress_active_tab_notifications, + *prev_state, + new_state, + ) { if let Some(agent_label) = self .app .state @@ -3319,15 +3330,11 @@ impl HeadlessServer { // Clients still decide locally whether they can execute the side effect. if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - *prev_state, - new_state, - prev_agent_label.as_deref(), - agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + *prev_state, + new_state, + ) { debug!(sound = ?sound, "forwarding sound notification from API request"); self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index 722f95c5..a575cfe1 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -19,20 +19,6 @@ impl HeadlessServer { .unwrap_or(crate::detect::AgentState::Unknown) } - fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option<String> { - self.app.state.workspaces.iter().find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - let pane = tab.panes.get(&pane_id)?; - self.app - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - .map(str::to_string) - }) - }) - } - fn forward_semantic_agent_notification( &mut self, update: &crate::app::actions::PaneStateUpdate, @@ -61,13 +47,9 @@ impl HeadlessServer { agent_label: Option<&str>, known_agent: Option<crate::detect::Agent>, ) -> bool { - let Some(kind) = crate::app::actions::notification_toast_for_state_change_with_agent_labels( - false, - previous_state, - state, - previous_agent_label, - agent_label, - ) else { + let Some(kind) = + crate::app::actions::notification_toast_for_state_change(false, previous_state, state) + else { return false; }; let Some(workspace) = self.app.state.workspaces.get(ws_idx) else { @@ -142,15 +124,11 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - update.previous_state, - update.state, - update.previous_agent_label.as_deref(), - update.agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + update.previous_state, + update.state, + ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -360,11 +338,8 @@ impl HeadlessServer { let pane_id_val = *pane_id; let agent_val = *agent; - // Find the previous effective state of this pane before the event - // is processed. Notifications must follow effective state changes, - // not raw fallback reports that may be masked by hook authority. + // Notifications follow effective changes, not fallback reports masked by hooks. let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the @@ -396,21 +371,16 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); if !suppress_completion && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + prev_state, + next_state, + ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -437,7 +407,6 @@ impl HeadlessServer { suppress_active_tab_notifications, prev_state, next_state, - prev_agent_label.as_deref(), ) } } else { @@ -465,11 +434,8 @@ impl HeadlessServer { let pane_id_val = *pane_id; let agent_val = crate::detect::parse_agent_label(agent_label); - // Capture the previous effective state for this pane. Hook reports - // are already folded into pane.state; raw hook transitions must not - // produce a second notification path. + // Hook reports are already folded into the effective state. let prev_state = self.pane_effective_state(pane_id_val); - let prev_agent_label = self.pane_effective_agent_label(pane_id_val); self.sync_foreground_client_state(); let pane_updates = self.app.handle_internal_event_with_pane_updates(ev); @@ -499,21 +465,16 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); - let next_agent_label = self.pane_effective_agent_label(pane_id_val); if !suppress_completion && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { - if let Some(sound) = - crate::app::actions::notification_sound_for_state_change_with_agent_labels( - suppress_active_tab_notifications, - prev_state, - next_state, - prev_agent_label.as_deref(), - next_agent_label.as_deref(), - ) - { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + prev_state, + next_state, + ) { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -540,7 +501,6 @@ impl HeadlessServer { suppress_active_tab_notifications, prev_state, next_state, - prev_agent_label.as_deref(), ) } } else { diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index dd2cce38..82564ba3 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -483,7 +483,7 @@ impl HeadlessServer { let Some(client) = self.clients.get_mut(&client_id) else { continue; }; - let mut candidate = client_shell_snapshot( + let (mut candidate, mut completions) = client_shell_snapshot( &self.app, &self.client_shell_boot_id, client.shell_projection_revision, @@ -497,11 +497,26 @@ impl HeadlessServer { }; candidate.revision = client.shell_projection_revision; if client.shell_snapshot.as_ref() != Some(&candidate) + || client.shell_agent_completions.as_ref() != Some(&completions) || client.shell_agent_view != agent_view { client.shell_projection_revision = client.shell_projection_revision.saturating_add(1); candidate.revision = client.shell_projection_revision; + completions.revision = candidate.revision; + let completion_framed = + match crate::protocol::endpoint::agent_completions_message(&completions) + .map_err(std::io::Error::other) + .and_then(|message| { + Self::frame_server_message(&message).map_err(std::io::Error::other) + }) { + Ok(message) => message, + Err(err) => { + warn!(client_id, err = %err, "failed to frame agent completions"); + broken_clients.push(client_id); + continue; + } + }; let projection_message = if agent_view.is_some() || client.shell_agent_view.is_some() { @@ -554,12 +569,14 @@ impl HeadlessServer { continue; }; if projection_framed.is_some_and(|framed| writer.control.send(framed).is_err()) + || writer.control.send(completion_framed).is_err() || writer.control.send(snapshot_framed).is_err() { broken_clients.push(client_id); continue; } client.shell_snapshot = Some(candidate); + client.shell_agent_completions = Some(completions); client.shell_agent_view = agent_view; } shell_projection_revision = client.shell_projection_revision; diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index cf6d389b..6accdb09 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -9,12 +9,36 @@ mod surface_delta_tests; #[path = "surface_interest.rs"] mod surface_interest_tests; -fn client_shell_snapshot(message: ServerMessage) -> Box<crate::protocol::ClientShellSnapshot> { - let ServerMessage::EndpointControl { kind, data } = message else { - panic!("expected client shell snapshot"); +fn client_shell_projection( + receiver: &std::sync::mpsc::Receiver<Vec<u8>>, +) -> ( + Box<protocol::ClientShellSnapshot>, + protocol::endpoint::EndpointAgentCompletions, +) { + let read_control = |expected| { + let ServerMessage::EndpointControl { kind, data } = read_server_message( + receiver + .recv_timeout(Duration::from_secs(1)) + .expect("endpoint projection"), + ) else { + panic!("expected endpoint control {expected}"); + }; + assert_eq!(kind, expected); + data }; - assert_eq!(kind, crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND); - Box::new(serde_json::from_str(&data).expect("decode client shell snapshot")) + let completions: protocol::endpoint::EndpointAgentCompletions = + serde_json::from_str(&read_control(protocol::endpoint::AGENT_COMPLETIONS_KIND)).unwrap(); + let snapshot: Box<protocol::ClientShellSnapshot> = + serde_json::from_str(&read_control(protocol::endpoint::ENDPOINT_SNAPSHOT_KIND)).unwrap(); + assert_eq!(completions.boot_id, snapshot.boot_id); + assert_eq!(completions.revision, snapshot.revision); + (snapshot, completions) +} + +fn client_shell_snapshot( + receiver: &std::sync::mpsc::Receiver<Vec<u8>>, +) -> Box<protocol::ClientShellSnapshot> { + client_shell_projection(receiver).0 } fn client_agent_view_projection( @@ -714,6 +738,64 @@ async fn client_shell_attach_seeds_workspace() { shutdown_test_runtimes(&mut server); } +#[tokio::test] +async fn completion_guard_endpoint_pairs_runtime_completions_with_snapshots() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("endpoint"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + let (writer, control_rx, _render_rx) = test_client_writer(); + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 78, + surface_cols: 80, + surface_rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + surface_active: false, + surface_reuse: false, + surface_delta: false, + writer, + }); + let (_, initial) = client_shell_projection(&control_rx); + assert!(initial.completions.is_empty()); + for first_state in [ + crate::detect::AgentState::Working, + crate::detect::AgentState::Unknown, + ] { + for state in [first_state, crate::detect::AgentState::Idle] { + server.app.state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Pi), + state, + visible_blocker: false, + visible_working: state == crate::detect::AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + server.render_and_stream(); + let (snapshot, completions) = client_shell_projection(&control_rx); + assert_eq!(snapshot.agents.len(), 1); + let agent = &snapshot.agents[0]; + let expected = + (first_state == crate::detect::AgentState::Working).then_some(agent.state_change_seq); + assert_eq!( + completions.completions.get(&agent.pane_id).copied(), + expected + ); + assert_eq!( + server.app.session_snapshot().agents[0].completion_seq, + expected + ); + } +} + #[tokio::test] async fn client_shell_endpoint_request_uses_the_selected_connection() { let mut server = test_headless_server(); @@ -739,7 +821,7 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() { writer, }) ); - let _initial_snapshot = control_rx.recv().expect("initial shell snapshot"); + let _initial_snapshot = client_shell_snapshot(&control_rx); let boot_id = server.client_shell_boot_id.clone(); assert!( @@ -857,9 +939,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots( writer, }) ); - let initial = client_shell_snapshot(read_server_message( - control_rx.recv().expect("initial snapshot"), - )); + let initial = client_shell_snapshot(&control_rx); let mut view = AgentViewSetParams { source: "example.views".into(), @@ -875,9 +955,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots( let set = client_agent_view_projection(read_server_message( control_rx.recv().expect("set projection"), )); - let set_snapshot = client_shell_snapshot(read_server_message( - control_rx.recv().expect("set snapshot"), - )); + let set_snapshot = client_shell_snapshot(&control_rx); assert!(set.revision > initial.revision); assert_eq!(set.revision, set_snapshot.revision); assert_eq!( @@ -894,9 +972,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots( let replacement = client_agent_view_projection(read_server_message( control_rx.recv().expect("replacement projection"), )); - let replacement_snapshot = client_shell_snapshot(read_server_message( - control_rx.recv().expect("replacement snapshot"), - )); + let replacement_snapshot = client_shell_snapshot(&control_rx); assert!(replacement.revision > set.revision); assert_eq!(replacement.revision, replacement_snapshot.revision); assert_eq!( @@ -913,9 +989,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots( let cleared = client_agent_view_projection(read_server_message( control_rx.recv().expect("clear projection"), )); - let cleared_snapshot = client_shell_snapshot(read_server_message( - control_rx.recv().expect("clear snapshot"), - )); + let cleared_snapshot = client_shell_snapshot(&control_rx); assert!(cleared.revision > replacement.revision); assert_eq!(cleared.revision, cleared_snapshot.revision); assert!(cleared.view.is_none()); @@ -967,9 +1041,7 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() { writer, }) ); - let snapshot = client_shell_snapshot(read_server_message( - control_rx.recv().expect("shell snapshot"), - )); + let snapshot = client_shell_snapshot(&control_rx); assert_eq!(snapshot.workspaces.len(), 1); assert_eq!(snapshot.workspaces[0].label, "shell-only-label"); assert_eq!( @@ -1599,9 +1671,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { writer: local_writer, }) ); - let local_snapshot = client_shell_snapshot(read_server_message( - local_control.recv().expect("local shell snapshot"), - )); + let local_snapshot = client_shell_snapshot(&local_control); assert_eq!( local_snapshot.config_diagnostic.as_deref(), Some("theme warning") @@ -1625,9 +1695,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { writer: endpoint_writer, }) ); - let endpoint_snapshot = client_shell_snapshot(read_server_message( - endpoint_control.recv().expect("endpoint shell snapshot"), - )); + let endpoint_snapshot = client_shell_snapshot(&endpoint_control); assert_eq!( endpoint_snapshot.config_diagnostic.as_deref(), Some("server keybinding warning\ntheme warning") @@ -1658,12 +1726,8 @@ async fn client_shell_tab_focus_changes_only_the_source_connection() { let (first_control, _first_render) = connect_test_shell(&mut server, 7, 100, 30); let (second_control, _second_render) = connect_test_shell(&mut server, 8, 80, 24); - let first_initial = client_shell_snapshot(read_server_message( - first_control.recv().expect("first snapshot"), - )); - let second_initial = client_shell_snapshot(read_server_message( - second_control.recv().expect("second snapshot"), - )); + let first_initial = client_shell_snapshot(&first_control); + let second_initial = client_shell_snapshot(&second_control); assert_eq!( first_initial.focused_tab_id.as_deref(), Some(first_tab_id.as_str()) @@ -1699,9 +1763,7 @@ async fn client_shell_tab_focus_changes_only_the_source_connection() { first_control.try_recv().is_err(), "another shell must not receive a navigation replacement" ); - let second_replacement = client_shell_snapshot(read_server_message( - second_control.recv().expect("second replacement snapshot"), - )); + let second_replacement = client_shell_snapshot(&second_control); assert_eq!( second_replacement.focused_tab_id.as_deref(), Some(second_tab_id.as_str()) @@ -2433,13 +2495,11 @@ async fn public_agent_focus_replaces_a_diverged_client_shell_projection() { let second_tab_id = server.app.public_tab_id(1, 0).unwrap(); let (control_rx, render_rx) = connect_test_shell(&mut server, 9, 80, 23); - let _ = control_rx.recv().expect("initial snapshot"); + let _ = client_shell_snapshot(&control_rx); assert!(server.focus_shell_client_on_tab(9, &second_tab_id)); assert!(server.claim_shell_tab_geometry(9, false)); server.render_and_stream(); - let diverged = client_shell_snapshot(read_server_message( - control_rx.recv().expect("diverged snapshot"), - )); + let diverged = client_shell_snapshot(&control_rx); assert_eq!( diverged.focused_workspace_id.as_deref(), Some(server.app.public_workspace_id(1).as_str()) @@ -2484,9 +2544,7 @@ async fn public_agent_focus_replaces_a_diverged_client_shell_projection() { assert_eq!(location.focused_tab_id(), Some(first_tab_id.as_str())); server.render_and_stream(); - let replacement = client_shell_snapshot(read_server_message( - control_rx.recv().expect("agent focus replacement snapshot"), - )); + let replacement = client_shell_snapshot(&control_rx); assert_eq!( replacement.focused_workspace_id.as_deref(), Some(first_workspace_id.as_str()) @@ -2529,10 +2587,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() { writer, }) ); - let initial_revision = client_shell_snapshot(read_server_message( - control_rx.recv().expect("initial snapshot"), - )) - .revision; + let initial_revision = client_shell_snapshot(&control_rx).revision; let (respond_to, _response_rx) = std::sync::mpsc::channel(); server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage { @@ -2551,9 +2606,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() { assert_eq!(server.app.state.active, Some(1)); server.render_and_stream(); - let replacement = client_shell_snapshot(read_server_message( - control_rx.recv().expect("replacement snapshot"), - )); + let replacement = client_shell_snapshot(&control_rx); assert!(replacement.revision > initial_revision); assert_eq!( replacement.focused_workspace_id.as_deref(), @@ -2781,9 +2834,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() { writer, }) ); - let _snapshot = client_shell_snapshot(read_server_message( - control_rx.recv().expect("shell snapshot"), - )); + let _snapshot = client_shell_snapshot(&control_rx); server.render_and_stream(); let ServerMessage::PaneSurface(surface) = @@ -6726,6 +6777,225 @@ fn notification_show_api_includes_sound_in_semantic_event() { } } +fn completion_guard_server(writer: ClientWriter) -> (HeadlessServer, crate::layout::PaneId) { + let mut server = test_headless_server(); + server.app.state.workspaces = ["background", "active"] + .map(crate::workspace::Workspace::test_new) + .into(); + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(1); + server.app.state.toast_config.delay_seconds = 0; + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + Default::default(), + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ), + ); + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + (server, pane_id) +} + +fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema::Method) { + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "completion-probe".into(), + method, + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + serde_json::from_str::<api::schema::SuccessResponse>(&response).expect("successful report"); +} + +fn completion_guard_notifications( + server: &mut HeadlessServer, + receiver: &std::sync::mpsc::Receiver<Vec<u8>>, +) -> Vec<protocol::SemanticNotificationKind> { + server.send_to_client( + 1, + ServerMessage::EndpointControl { + kind: "test.completion-barrier".into(), + data: String::new(), + }, + ); + let mut messages = Vec::new(); + loop { + let message = read_server_message( + receiver + .recv_timeout(Duration::from_secs(1)) + .expect("notification barrier"), + ); + if matches!(&message, ServerMessage::EndpointControl { kind, .. } if kind == "test.completion-barrier") + { + return messages; + } + if let ServerMessage::SemanticNotification(notification) = message { + messages.push(notification.kind); + } + } +} + +#[test] +fn completion_guard_api_startup_blocker_respects_suppression() { + use protocol::SemanticNotificationKind::{Finished, NeedsAttention}; + + let (writer, control_rx, _render_rx) = test_client_writer(); + let (mut server, pane_id) = completion_guard_server(writer); + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Pi, + observed_at: Instant::now(), + }); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + for (seq, state) in [ + api::schema::PaneAgentState::Blocked, + api::schema::PaneAgentState::Idle, + ] + .into_iter() + .enumerate() + { + completion_guard_api_report( + &mut server, + api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id.clone(), + source: "custom:pi".into(), + agent: "pi".into(), + state, + message: None, + seq: Some(seq as u64 + 1), + agent_session_id: None, + agent_session_path: None, + }), + ); + } + let notifications = completion_guard_notifications(&mut server, &control_rx); + assert!(notifications.contains(&NeedsAttention)); + assert!( + !notifications.contains(&Finished), + "startup: {notifications:?}" + ); +} + +#[test] +fn completion_guard_api_session_replacement_does_not_notify_finished() { + use crate::agent_resume::{AgentSessionRef, PersistedAgentSession}; + use crate::detect::{Agent, AgentState}; + use api::schema::{ + Method, PaneAgentState, PaneReportAgentParams, PaneReportAgentSessionParams, + }; + use protocol::SemanticNotificationKind::Finished; + + for fallback_state in [ + AgentState::Unknown, + AgentState::Working, + AgentState::Blocked, + ] { + for reason in ["new", "resume", "fork"] { + let (writer, control_rx, _render_rx) = test_client_writer(); + let (mut server, pane_id) = completion_guard_server(writer); + let terminal_id = server.app.state.workspaces[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = server.app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Pi), fallback_state); + let cwd = std::env::current_dir().unwrap(); + let old_session = + AgentSessionRef::path(cwd.join("completion-old.jsonl").display().to_string()) + .unwrap(); + let new_session = cwd.join("completion-new.jsonl").display().to_string(); + terminal.set_persisted_agent_session(PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: old_session.clone(), + }); + terminal + .set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + Some(old_session), + Some(10), + ) + .expect("initial authority"); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + completion_guard_api_report( + &mut server, + Method::PaneReportAgentSession(PaneReportAgentSessionParams { + pane_id: public_pane_id.clone(), + source: "herdr:pi".into(), + agent: "pi".into(), + seq: Some(11), + agent_session_id: None, + agent_session_path: Some(new_session.clone()), + session_start_source: Some(reason.into()), + }), + ); + let mut report = PaneReportAgentParams { + pane_id: public_pane_id, + source: "herdr:pi".into(), + agent: "pi".into(), + state: PaneAgentState::Idle, + message: None, + seq: Some(12), + agent_session_id: None, + agent_session_path: Some(new_session.clone()), + }; + completion_guard_api_report(&mut server, Method::PaneReportAgent(report.clone())); + let terminal = &server.app.state.terminals[&terminal_id]; + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!( + terminal + .hook_authority + .as_ref() + .unwrap() + .session_ref + .as_ref() + .unwrap() + .value, + new_session + ); + let notifications = completion_guard_notifications(&mut server, &control_rx); + assert!( + !notifications.contains(&Finished), + "{reason}: {notifications:?}" + ); + assert!(server.app.state.workspaces[0].panes[&pane_id].seen); + for (seq, state, expected) in [ + (13, PaneAgentState::Working, AgentState::Working), + (14, PaneAgentState::Idle, AgentState::Idle), + ] { + report.seq = Some(seq); + report.state = state; + completion_guard_api_report(&mut server, Method::PaneReportAgent(report.clone())); + let terminal = &server.app.state.terminals[&terminal_id]; + assert_eq!(terminal.state, expected); + assert_eq!( + terminal.last_agent_completion_seq.is_some(), + expected == AgentState::Idle + ); + } + let finished = completion_guard_notifications(&mut server, &control_rx) + .into_iter() + .filter(|kind| *kind == Finished) + .count(); + assert_eq!( + finished, 1, + "the first real turn after {reason} must finish" + ); + } + } +} + #[test] fn startup_idle_does_not_forward_completion() { let mut server = test_headless_server(); diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 0b7a8095..3e36a8fc 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -56,7 +56,8 @@ async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once #[tokio::test] async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell graphics"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -92,7 +93,8 @@ async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { #[tokio::test] async fn first_kitty_image_updates_retained_surface_without_full_redraw() { - let (mut server, client_rx, pane_id) = retained_test_server(b"text before image"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"text before image"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -183,7 +185,8 @@ async fn first_kitty_image_updates_retained_surface_without_full_redraw() { #[tokio::test] async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() { - let (mut server, client_rx, pane_id) = retained_test_server(b"\x1b[?1049h"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"\x1b[?1049h"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.cell_size = crate::kitty_graphics::HostCellSize { @@ -260,7 +263,8 @@ async fn render_scale_profile_retained_graphics() { for retained in [false, true] { for with_image in [false, true] { for count in [1, 15] { - let (mut server, client_rx, root) = retained_test_server(b"populated terminal\r\n"); + let (mut server, _control_rx, client_rx, root) = + retained_test_server_with_control(b"populated terminal\r\n"); let mut pane_ids = vec![root]; for index in 1..count { let workspace = &mut server.app.state.workspaces[0]; @@ -336,8 +340,9 @@ async fn render_scale_profile_retained_graphics() { #[tokio::test] async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() { - let (mut server, client_rx, _pane_id) = - retained_test_server(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\"); + let (mut server, _control_rx, client_rx, _pane_id) = retained_test_server_with_control( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -366,7 +371,7 @@ async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_ #[tokio::test] async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { - let (mut server, client_rx, _pane_id) = retained_test_server( + let (mut server, _control_rx, client_rx, _pane_id) = retained_test_server_with_control( b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\\x1b_Ga=T,f=32,t=d,i=8,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); let client = server.clients.get_mut(&1).unwrap(); @@ -406,7 +411,8 @@ async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { #[tokio::test] async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell graphics"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell graphics"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = @@ -975,7 +981,8 @@ fn resident_direct_stream_survives_non_direct_client_becoming_foreground() { #[cfg(unix)] #[tokio::test] async fn client_shell_direct_graphics_uploads_without_server_authored_coordinates() { - let (mut server, client_rx, pane_id) = retained_test_server(b"client shell direct"); + let (mut server, _control_rx, client_rx, pane_id) = + retained_test_server_with_control(b"client shell direct"); server.app.state.kitty_graphics_enabled = true; let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; diff --git a/src/server/headless/tests/pane_move.rs b/src/server/headless/tests/pane_move.rs index c18d0f1f..789b144f 100644 --- a/src/server/headless/tests/pane_move.rs +++ b/src/server/headless/tests/pane_move.rs @@ -55,7 +55,7 @@ async fn public_pane_move_focus_follows_the_moved_pane() { let source_id = server.app.public_pane_id(0, source).unwrap(); let destination_id = server.app.public_workspace_id(1); let (control_rx, render_rx) = connect_test_shell(&mut server, 9, 80, 23); - let initial = client_shell_snapshot(read_server_message(control_rx.recv().unwrap())); + let initial = client_shell_snapshot(&control_rx); let moved = public_move( &mut server, @@ -81,7 +81,7 @@ async fn public_pane_move_focus_follows_the_moved_pane() { assert_eq!(location.focused_tab_id(), Some(moved.pane.tab_id.as_str())); server.render_and_stream(); - let snapshot = client_shell_snapshot(read_server_message(control_rx.recv().unwrap())); + let snapshot = client_shell_snapshot(&control_rx); assert!(snapshot.revision > initial.revision); assert_eq!( snapshot.focused_workspace_id.as_deref(), diff --git a/src/server/headless/tests/surface_interest.rs b/src/server/headless/tests/surface_interest.rs index 7c8b33f8..f3acc27b 100644 --- a/src/server/headless/tests/surface_interest.rs +++ b/src/server/headless/tests/surface_interest.rs @@ -79,10 +79,7 @@ async fn metadata_only_shell_is_isolated_until_surface_activation() { writer, }) ); - assert!(matches!( - read_server_message(control_rx.recv().expect("metadata snapshot")), - ServerMessage::EndpointControl { .. } - )); + let _ = client_shell_snapshot(&control_rx); assert_eq!(server.foreground_client_id, None); assert_eq!(server.effective_size, original_size); @@ -410,7 +407,7 @@ async fn presentation_sync_epoch_replays_modes_and_title() { writer, }) ); - let _ = control_rx.recv().expect("initial snapshot"); + let _ = client_shell_snapshot(&control_rx); server.api_window_title = Some("target title".into()); { let client = server.clients.get_mut(&client_id).unwrap(); @@ -526,11 +523,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { writer: source_writer, }) ); - let source_snapshot = client_shell_snapshot(read_server_message( - source_control - .recv() - .expect("real source metadata snapshot"), - )); + let source_snapshot = client_shell_snapshot(&source_control); let mut target_server = test_headless_server(); let _target_input = install_focused_test_runtime(&mut target_server, b"remote target"); @@ -553,11 +546,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { writer: target_writer, }) ); - let remote_snapshot = client_shell_snapshot(read_server_message( - target_control - .recv() - .expect("real target metadata snapshot"), - )); + let remote_snapshot = client_shell_snapshot(&target_control); let profile = SavedSshEndpoint { id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), @@ -685,9 +674,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { ); target_server.render_and_stream(); - let coherent_snapshot = client_shell_snapshot(read_server_message( - target_control.recv().expect("target replacement snapshot"), - )); + let coherent_snapshot = client_shell_snapshot(&target_control); let snapshot_progress = activation.receive_snapshot(&target_id, 7, &coherent_snapshot); shell.set_endpoint_snapshot(&target_id, coherent_snapshot); assert_eq!( diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 4ce33a24..724156b8 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -25,7 +25,6 @@ pub(crate) fn toast_message_from_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, - previous_agent_label: Option<&str>, ) -> Option<String> { state .workspaces @@ -38,12 +37,10 @@ pub(crate) fn toast_message_from_state_change( .terminals .get(&pane.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label())?; - let kind = app::actions::notification_toast_for_state_change_with_agent_labels( + let kind = app::actions::notification_toast_for_state_change( suppress_active_tab_notifications, prev_state, new_state, - previous_agent_label, - Some(agent_label), )?; let workspace_label = ws.display_name_from(&state.terminals, terminal_runtimes); Some(format!( @@ -142,7 +139,6 @@ mod tests { false, AgentState::Working, AgentState::Idle, - Some("codex"), ); assert_eq!( diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index 5c3935de..dcafa1a9 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -233,13 +233,36 @@ fn profile_snapshot_encoding( count: usize, client_count: usize, ) -> StageStats { - let pipeline = RenderPipeline::new(build(count)); + let mut pipeline = RenderPipeline::new(build(count)); + pipeline.app.state.ensure_test_terminals(); + for terminal in pipeline.app.state.terminals.values_mut() { + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + terminal.last_agent_state_change_seq = Some(1); + terminal.last_agent_completion_seq = Some(1); + } let run = || { let started = Instant::now(); - let template = super::client_shell::snapshot(&pipeline.app, "bench-boot", 1, None, None); + let (template, completions) = super::client_shell::snapshot_with_completions( + &pipeline.app, + "bench-boot", + 1, + None, + None, + ); for client_index in 0..client_count { let mut snapshot = template.clone(); snapshot.revision = client_index as u64 + 1; + let mut completions = completions.clone(); + completions.revision = snapshot.revision; + let companion = crate::protocol::endpoint::agent_completions_message(&completions) + .expect("benchmark completion projection should serialize"); + black_box( + bincode::serde::encode_to_vec(companion, bincode::config::standard()) + .expect("benchmark completion projection should frame"), + ); let message = crate::protocol::endpoint::snapshot_message(&snapshot) .expect("benchmark snapshot should serialize"); black_box( @@ -256,7 +279,7 @@ fn profile_snapshot_encoding( } fn print_snapshot_encoding_profiles(label: &str, build: fn(usize) -> Vec<Workspace>) { - println!("{label} snapshot projection + JSON framing"); + println!("{label} populated agent snapshots + completions + JSON framing"); println!(" panes clients median_us p95_us max_us"); for count in CARDINALITIES { for client_count in CLIENT_CARDINALITIES { diff --git a/src/terminal/state.rs b/src/terminal/state.rs index c084b034..119916c8 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -142,6 +142,7 @@ pub struct TerminalState { metadata_token_sequence_sources: std::collections::HashSet<String>, pub state: AgentState, pub last_agent_state_change_seq: Option<u64>, + pub last_agent_completion_seq: Option<u64>, pub revision: u64, pub launch_argv: Option<Vec<String>>, pub respawn_shell_on_exit: bool, @@ -178,6 +179,7 @@ impl TerminalState { metadata_token_sequence_sources: std::collections::HashSet::new(), state: AgentState::Unknown, last_agent_state_change_seq: None, + last_agent_completion_seq: None, revision: 0, launch_argv: None, respawn_shell_on_exit: false, @@ -584,14 +586,18 @@ impl TerminalState { if agent.is_none() && self.recent_agent_process_exit.is_some() { self.clear_agent_name(); } + let effective_state_change = self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ); + if fallback_state == AgentState::Working && self.state == AgentState::Working { + self.agent_process_acquisition_pending = false; + } TerminalStateMutation { - effective_state_change: self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - previous_presentation, - now, - ), + effective_state_change, session_ref_changed: previous_session != self.current_session_identity_for_persistence(), agent_released, @@ -742,14 +748,18 @@ impl TerminalState { session_ref, }); let current_session = self.current_session_identity_for_persistence(); + let effective_state_change = self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ); + if state == AgentState::Working && self.state == AgentState::Working { + self.agent_process_acquisition_pending = false; + } Some(TerminalStateMutation { - effective_state_change: self.recompute_effective_state( - previous_agent_label, - previous_known_agent, - previous_state, - previous_presentation, - now, - ), + effective_state_change, session_ref_changed: previous_session != current_session, agent_released: false, }) @@ -1615,6 +1625,10 @@ impl TerminalState { } self.persisted_agent_session = Some(persisted_session); let current_session = self.current_session_identity_for_persistence(); + if previous_session.is_some() && previous_session != current_session { + // Rebinding can expose a cached Working screen; only a fresh report ends acquisition. + self.agent_process_acquisition_pending = true; + } Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, @@ -1925,6 +1939,7 @@ impl TerminalState { timeout: Duration, ) { self.set_agent_name(name); + self.agent_process_acquisition_pending = true; self.agent_name_owner = Some(AgentNameOwner { agent_label: crate::detect::agent_label(kind).to_string(), session_ref: None, @@ -2092,6 +2107,7 @@ impl TerminalState { self.stale_full_lifecycle_hook_sessions.clear(); self.state = AgentState::Unknown; self.last_agent_state_change_seq = None; + self.last_agent_completion_seq = None; self.launch_argv = None; self.respawn_shell_on_exit = false; self.recent_agent_process_exit = None; From 8ac9542757292f7a8d42a2d532bc6a8a33c7ffce Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Tue, 22 Sep 2026 02:20:47 +0300 Subject: [PATCH 41/99] fix: clear inherited terminal and agent session identity (#4461) refs #4104 --- .../src/content/docs/cli-reference.mdx | 11 ++ src/pane.rs | 110 +++++++++++++++--- 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index cf9b355f..12de68b7 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -517,6 +517,17 @@ terminal size, and values smaller than the popup minimum are clamped. A popup is not a Herdr pane, does not export `HERDR_PANE_ID`, and does not participate in pane or agent APIs. Native non-terminal plugin panes are outside plugin v1. +New panes identify as `TERM_PROGRAM=herdr`, with `TERM_PROGRAM_VERSION` set to +Herdr's version. They keep `TERM=xterm-256color` and `COLORTERM=truecolor`. +Herdr removes inherited outer-terminal session markers (iTerm2, WezTerm, Kitty, +Windows Terminal, tmux, screen, and Zellij), iTerm2's `LC_TERMINAL` identity, +and known Claude Code, Codex, and OMP session markers before applying explicit +launch environment values. This prevents new panes from claiming the terminal +or agent session that originally started the server. Ordinary environment +variables, including API keys and display settings, remain inherited rather than +being replaced with the attaching client's environment. SSH-agent forwarding has +its own reconnect handling. Existing pane processes are unchanged by this cleanup. + `--env KEY=VALUE` can be repeated on process-launching commands. It applies to the newly launched process only. Herdr-managed variables such as `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ENV`, `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, `HERDR_PANE_ID`, `HERDR_PLUGIN_ID`, `HERDR_PLUGIN_ROOT`, `HERDR_PLUGIN_CONFIG_DIR`, `HERDR_PLUGIN_STATE_DIR`, `HERDR_PLUGIN_ENTRYPOINT_ID`, and `HERDR_PLUGIN_CONTEXT_JSON` stay authoritative when they conflict with caller-provided env. ## Read sources diff --git a/src/pane.rs b/src/pane.rs index 485b226c..9d75c21c 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -96,7 +96,25 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { // when the remote side lacks matching terminfo entries. cmd.env("TERM", PANE_TERM); cmd.env("COLORTERM", PANE_COLORTERM); - cmd.env_remove("WT_SESSION"); + cmd.env("TERM_PROGRAM", "herdr"); + cmd.env("TERM_PROGRAM_VERSION", crate::build_info::version()); + // Host handles refer to the outer terminal, never to this pane. + for key in [ + "ITERM_SESSION_ID", + "LC_TERMINAL", + "LC_TERMINAL_VERSION", + "WEZTERM_PANE", + "KITTY_WINDOW_ID", + "WT_SESSION", + "TMUX", + "TMUX_PANE", + "STY", + "ZELLIJ", + "ZELLIJ_SESSION_NAME", + "ZELLIJ_PANE_ID", + ] { + cmd.env_remove(key); + } } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -148,10 +166,18 @@ impl PaneLaunchEnv { fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) { #[cfg(unix)] crate::platform::ssh_agent::apply_pane_env(cmd); - cmd.env_remove("CODEX_THREAD_ID"); - // OMP sets OMPCODE for shells it spawns. A pane launched from inside OMP - // must not inherit it or its root agent would look like a nested session. - cmd.env_remove("OMPCODE"); + // A new pane is not a child agent of the process that started the server. + // Explicit launch env below can opt back into an intentional child session. + for key in [ + "CODEX_THREAD_ID", + "OMPCODE", + "CLAUDECODE", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_MESSAGING_TOKEN", + ] { + cmd.env_remove(key); + } for (key, value) in &launch_env.extra { cmd.env(key, value); } @@ -3715,6 +3741,7 @@ impl PaneRuntime { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsStr; #[tokio::test] async fn clear_pane_preserves_wrapped_input_and_unfinished_vt_sequence() { @@ -3824,33 +3851,84 @@ mod tests { } #[test] - fn pane_launch_env_removes_outer_codex_thread_id() { + fn pane_launch_env_removes_outer_agent_identity() { + let keys = [ + "CODEX_THREAD_ID", + "OMPCODE", + "CLAUDECODE", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_MESSAGING_TOKEN", + ]; let mut cmd = CommandBuilder::new("shell"); - cmd.env("CODEX_THREAD_ID", "outer-session"); + for key in keys { + cmd.env(key, "outer-session"); + } + cmd.env("ANTHROPIC_API_KEY", "fake-api-key"); + cmd.env("DISPLAY", ":42"); apply_pane_launch_env(&mut cmd, &PaneLaunchEnv::default()); - assert!(cmd.get_env("CODEX_THREAD_ID").is_none()); + for key in keys { + assert!(cmd.get_env(key).is_none(), "{key} must not leak into panes"); + } + assert_eq!( + cmd.get_env("ANTHROPIC_API_KEY"), + Some(OsStr::new("fake-api-key")) + ); + assert_eq!(cmd.get_env("DISPLAY"), Some(OsStr::new(":42"))); } #[test] - fn pane_launch_env_removes_outer_ompcode_marker() { + fn pane_terminal_identity_removes_outer_terminal_identity() { + let keys = [ + "ITERM_SESSION_ID", + "LC_TERMINAL", + "LC_TERMINAL_VERSION", + "WEZTERM_PANE", + "KITTY_WINDOW_ID", + "WT_SESSION", + "TMUX", + "TMUX_PANE", + "STY", + "ZELLIJ", + "ZELLIJ_SESSION_NAME", + "ZELLIJ_PANE_ID", + ]; let mut cmd = CommandBuilder::new("shell"); - cmd.env("OMPCODE", "1"); + for key in keys { + cmd.env(key, "outer-session"); + } + cmd.env("TERM_PROGRAM", "iTerm.app"); + cmd.env("TERM_PROGRAM_VERSION", "outer-version"); - apply_pane_launch_env(&mut cmd, &PaneLaunchEnv::default()); + apply_pane_terminal_env(&mut cmd); - assert!(cmd.get_env("OMPCODE").is_none()); + for key in keys { + assert!(cmd.get_env(key).is_none(), "{key} must not leak into panes"); + } + assert_eq!(cmd.get_env("TERM_PROGRAM"), Some(OsStr::new("herdr"))); + assert_eq!( + cmd.get_env("TERM_PROGRAM_VERSION"), + Some(OsStr::new(&crate::build_info::version())) + ); } #[test] - fn pane_terminal_identity_removes_outer_windows_terminal_session() { + fn pane_launch_env_allows_explicit_session_identity() { + let extra = vec![ + ("CLAUDE_CODE_CHILD_SESSION".into(), "1".into()), + ("CLAUDE_CODE_SESSION_ID".into(), "intentional-child".into()), + ("CLAUDE_CODE_MESSAGING_TOKEN".into(), "fake-token".into()), + ("ITERM_SESSION_ID".into(), "intentional-host".into()), + ]; let mut cmd = CommandBuilder::new("shell"); - cmd.env("WT_SESSION", "outer-session"); - apply_pane_terminal_env(&mut cmd); + apply_pane_launch_env(&mut cmd, &PaneLaunchEnv::from_extra(extra.clone())); - assert!(cmd.get_env("WT_SESSION").is_none()); + for (key, value) in extra { + assert_eq!(cmd.get_env(key), Some(OsStr::new(&value))); + } } #[tokio::test] From 46a5091838de66c1be193990afc3ad9145e6b4f5 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Tue, 22 Sep 2026 15:23:31 +0200 Subject: [PATCH 42/99] fix: stagger restored agent startups (#4487) * fix: stagger restored agent startups refs #4102 * fix: report startup delay changes requiring restart refs #4102 --- .../src/content/docs/configuration.mdx | 3 + .../src/content/docs/ja/configuration.mdx | 3 + .../src/content/docs/zh-cn/configuration.mdx | 3 + .../website/src/data/config-reference.json | 6 + src/app/agent_resume.rs | 132 ++++++++++++++++-- src/app/mod.rs | 36 +++++ src/config/model.rs | 6 + src/main.rs | 2 + src/server/headless.rs | 4 +- src/server/headless/client_views.rs | 2 +- 10 files changed, 182 insertions(+), 15 deletions(-) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index c54c79a9..3dae7168 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -532,8 +532,11 @@ Herdr resumes supported Agent conversations after a server restart by default: ```toml [session] resume_agents_on_restore = true +startup_per_agent_delay_ms = 100 ``` +Automatic restores start one agent at a time, 100 milliseconds apart by default, to reduce startup contention. The first eligible agent starts immediately. Set `startup_per_agent_delay_ms = 0` to disable spacing. Manual launches and explicit terminal attaches are unaffected. Restart the Herdr server to apply changes. + Only panes with a valid native session reference from an official integration can resume; other panes restore as normal shells. See [Session state and restore](/docs/session-state/) for supported Agents and persistence behavior. ## IME cursor tracking diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index 12802e84..65b683f8 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -528,8 +528,11 @@ Herdr はデフォルトで、サーバーの再起動後に対応する Agent ```toml [session] resume_agents_on_restore = true +startup_per_agent_delay_ms = 100 ``` +起動時の競合を減らすため、自動復元ではデフォルトで 100 ミリ秒間隔でエージェントを 1 つずつ起動します。起動条件を満たした最初のエージェントはすぐに起動します。`startup_per_agent_delay_ms = 0` で間隔を無効にできます。手動起動と明示的なターミナルへの接続には影響しません。変更を適用するには Herdr サーバーを再起動してください。 + 公式インテグレーションからの有効なネイティブセッション参照を持つペインだけが再開できます。その他のペインは通常のシェルとして復元されます。対応する Agent と永続化の挙動については、[セッション状態と復元](/docs/session-state/)を参照してください。 ## IME カーソルトラッキング diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 9c1d5b2d..8b8738d6 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -528,8 +528,11 @@ kitty_graphics = false ```toml [session] resume_agents_on_restore = true +startup_per_agent_delay_ms = 100 ``` +为减少启动时的资源争用,自动恢复默认每隔 100 毫秒启动一个智能体。第一个满足条件的智能体会立即启动。设置 `startup_per_agent_delay_ms = 0` 可禁用间隔。手动启动和显式连接终端不受影响。修改后需重启 Herdr 服务器。 + 只有通过官方集成获得有效原生会话引用的窗格才能恢复;其他窗格会作为普通 shell 恢复。有关受支持的智能体和持久化行为,请参阅[会话状态与恢复](/docs/session-state/)。 ## IME 光标跟踪 diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index ece9a833..76318e3a 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -1429,6 +1429,12 @@ "type": "boolean", "default": "true", "description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session." + }, + { + "key": "session.startup_per_agent_delay_ms", + "type": "integer", + "default": "100", + "description": "Milliseconds between automatic agent restores after a server restart. The first agent starts without this delay; 0 disables spacing. Does not delay manual launches or explicit terminal attaches. Restart the server to apply." } ] }, diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 9bf2157f..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 } @@ -375,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<String> { vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()] @@ -458,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 { @@ -475,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 @@ -537,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() { @@ -590,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(), @@ -651,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 @@ -712,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 @@ -772,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 @@ -831,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/mod.rs b/src/app/mod.rs index 757a7f66..7ff3040b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -136,6 +136,8 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option<Instant>, pub(crate) pending_agent_resume_deadline: Option<Instant>, + startup_per_agent_delay: Duration, + next_agent_resume_at: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>, session_writer: Arc<std::sync::Mutex<crate::persist::SessionWriter>>, @@ -600,6 +602,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, @@ -857,6 +863,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 @@ -1760,6 +1776,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(); diff --git a/src/config/model.rs b/src/config/model.rs index 930df026..ce59eb2e 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -268,12 +268,15 @@ pub struct SessionConfig { /// Resume supported AI-agent panes into their native conversation sessions /// when restoring a Herdr session. Default: true. pub resume_agents_on_restore: bool, + /// Milliseconds between automatic agent restores. Zero disables spacing. + pub startup_per_agent_delay_ms: u32, } impl Default for SessionConfig { fn default() -> Self { Self { resume_agents_on_restore: true, + startup_per_agent_delay_ms: 100, } } } @@ -1397,13 +1400,16 @@ new_cwd = "~/Projects" fn resume_agents_on_restore_defaults_on_and_parses() { let default_config = Config::default(); assert!(default_config.session.resume_agents_on_restore); + assert_eq!(default_config.session.startup_per_agent_delay_ms, 100); let toml = r#" [session] resume_agents_on_restore = false +startup_per_agent_delay_ms = 0 "#; let config: Config = toml::from_str(toml).unwrap(); assert!(!config.session.resume_agents_on_restore); + assert_eq!(config.session.startup_per_agent_delay_ms, 0); } #[test] diff --git a/src/main.rs b/src/main.rs index 5d7582cc..d5275529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -393,6 +393,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Resume supported AI-agent panes into their native conversation sessions after # a Herdr server restart. Requires official integrations that report session refs. # resume_agents_on_restore = true +# Milliseconds between automatic agent restores; 0 starts them without spacing. +# startup_per_agent_delay_ms = 100 [remote] # Whether herdr manages the ssh config used for `herdr --remote`. diff --git a/src/server/headless.rs b/src/server/headless.rs index c5a644ce..0aed2a37 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -808,7 +808,7 @@ impl HeadlessServer { self.app.sync_pending_agent_resume_deadline(now); if self .app - .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)) + .start_pending_agent_resumes(now, self.app.pending_agent_resume_due(now)) { for client in self.clients.values_mut() { client.request_recompute(); @@ -3445,7 +3445,7 @@ impl HeadlessServer { self.app.sync_pending_agent_resume_deadline(now); changed |= self .app - .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)); + .start_pending_agent_resumes(now, self.app.pending_agent_resume_due(now)); } changed } diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 62fd04e4..c2e3c2d5 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -527,7 +527,7 @@ impl HeadlessServer { self.app.sync_pending_agent_resume_deadline(now); if self .app - .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)) + .start_pending_agent_resumes(now, self.app.pending_agent_resume_due(now)) { for client in self.clients.values_mut() { client.request_recompute(); From f6390752a57a5ac3256f3213af870c0b62e224d7 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Tue, 22 Sep 2026 16:25:17 +0300 Subject: [PATCH 43/99] fix: track opencode subagent status per pane (#4357) refs #1362 --- .../website/src/content/docs/integrations.mdx | 4 + .../src/content/docs/ja/integrations.mdx | 4 + .../src/content/docs/zh-cn/integrations.mdx | 4 + .../assets/herdr-agent-state.test.ts | 3 + .../assets/opencode/herdr-agent-state.js | 22 +- .../assets/opencode/herdr-agent-state.test.ts | 27 +- .../assets/opencode/herdr-tui-session.js | 376 +++++++++++++++--- .../assets/opencode/herdr-tui-session.test.ts | 318 ++++++++++++++- src/integration/assets/opencode/tui.js | 2 +- src/integration/mod.rs | 2 +- 10 files changed, 698 insertions(+), 64 deletions(-) diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 7bca01e6..1261b38d 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -227,6 +227,10 @@ Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting. +V1 TUI reporting follows the pane's selected session and its descendants. The pane stays working while any of them is busy or retrying, and blocked while any has a pending permission or question. Attaching loads existing activity; cancelled tool requests do not become blockers again after reconnecting. Browsing a subagent keeps the original session family, while directly attaching to a child tracks that child's subtree rather than its siblings. + +Local V1 `opencode run` and `opencode --mini` retain their legacy server hooks; this change does not add pane-local lifecycle reporting to Mini/headless clients. After updating the integration, restart shared OpenCode servers as well as their TUIs so the old server reporter cannot overwrite pane-local state. + The plugin reports lifecycle state and session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. Native screen manifest detection remains available when the plugin is not installed. ## Kilo Code CLI diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index 6086e6b6..d54eaa88 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -229,6 +229,10 @@ herdr integration install opencode V2 のライフサイクル報告はペイン内の TUI で実行され、複数のペインが同じ OpenCode サーバーを共有する場合も、選択されたルートセッションにイベントを対応付けます。完了時または中断時に working 状態を解除し、未処理の権限要求やフォーム、実行失敗は blocked 状態を維持します。V2 Mini とヘッドレスクライアントは TUI プラグインを実行しないため、このライフサイクル報告は利用できません。 +V1 TUI はペインで選択したセッションとその子孫を追跡します。いずれかが実行中・再試行中なら working、権限要求や質問が未処理なら blocked を維持します。接続時に既存の状態を読み込み、キャンセル済みツールの要求が再接続後に blocked を復活させることはありません。サブエージェントの閲覧では元のセッション群を維持し、子セッションへ直接接続した場合は兄弟ではなくその子孫だけを追跡します。 + +ローカル V1 の `opencode run` と `opencode --mini` は従来のサーバーフックを維持します。この変更は Mini やヘッドレスクライアントにペイン固有のライフサイクル報告を追加するものではありません。連携の更新後は TUI と共有 OpenCode サーバーの両方を再起動し、古いサーバー報告がペインの状態を上書きしないようにしてください。 + このプラグインは、OpenCode が Herdr のペイン内で動いている間、ライフサイクル状態とセッション識別を報告します。OpenCode がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `opencode --session <id>` でペインを resume できます。プラグインがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。 ## Kilo Code CLI diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index 5eb1f322..32d97e08 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -229,6 +229,10 @@ herdr integration install opencode V2 生命周期上报在窗格本地的 TUI 中运行。即使多个窗格共享同一个 OpenCode 服务端,事件也只归属于该 TUI 选中的根会话。完成和中断会清除工作状态;待处理的权限请求、表单以及执行失败会使窗格保持阻塞。V2 Mini 和无界面客户端不运行 TUI 插件,因此不提供此生命周期上报。 +V1 TUI 跟踪窗格选中的会话及其后代。只要其中任何会话仍在忙碌或重试,就保持 working;存在待处理的权限请求或问题时保持 blocked。连接时会读取已有活动,已取消工具的请求不会在重新连接后再次变成阻塞项。查看子代理时保留原来的会话范围;直接连接子会话时只跟踪它及其后代,不包括兄弟会话。 + +本地 V1 的 `opencode run` 和 `opencode --mini` 保留原有服务端钩子;此变更不会为 Mini 或无界面客户端添加窗格本地的生命周期上报。更新集成后,请同时重启共享 OpenCode 服务器及其 TUI,避免旧服务端上报覆盖窗格本地状态。 + 该插件在 OpenCode 运行于 Herdr 窗格内时上报生命周期状态和会话身份。在 OpenCode 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `opencode --session <id>` 恢复该窗格。插件未安装时,屏幕清单检测仍然可用。 ## Kilo Code CLI diff --git a/src/integration/assets/herdr-agent-state.test.ts b/src/integration/assets/herdr-agent-state.test.ts index e0f0bd57..abfeb0ef 100644 --- a/src/integration/assets/herdr-agent-state.test.ts +++ b/src/integration/assets/herdr-agent-state.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; const originalPlatform = process.platform; +const originalArgv = process.argv; const originalCreateConnection = net.createConnection; const originalEnvironment = { HERDR_ENV: process.env.HERDR_ENV, @@ -35,6 +36,7 @@ afterEach(async () => { Object.defineProperty(process, "platform", { value: originalPlatform }); net.createConnection = originalCreateConnection; + process.argv = originalArgv; for (const [name, value] of Object.entries(originalEnvironment)) { if (value === undefined) { delete process.env[name]; @@ -137,6 +139,7 @@ for (const socketPlugin of socketPlugins) { Object.defineProperty(process, "platform", { value: "win32" }); const connectedEndpoint = captureConnectionEndpoint(); + process.argv = ["bun", "/$bunfs/root/src/index.js", "run"]; const { HerdrAgentStatePlugin } = await importFresh(socketPlugin.modulePath); const plugin = await HerdrAgentStatePlugin(); await plugin.event({ diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 73a70534..09c00704 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=opencode -// HERDR_INTEGRATION_VERSION=12 +// HERDR_INTEGRATION_VERSION=13 import net from "node:net"; @@ -118,8 +118,23 @@ function reportState(state, sessionID) { return request("pane.report_agent", params); } +function ownsLocalLifecycle() { + const args = process.argv.slice(2); + const separator = args.indexOf("--"); + if (separator !== -1) args.splice(separator); + if (args.some((arg) => arg === "--attach" || arg.startsWith("--attach="))) return false; + while (args[0] === "--print-logs" || args[0] === "--log-level" || args[0]?.startsWith("--log-level=")) { + args.splice(0, args[0] === "--log-level" ? 2 : 1); + } + // These local clients have no TUI plugin. Shared servers and the TUI worker + // cannot identify their attached panes; their lifecycle belongs to each TUI. + return args[0] === "run" || + (!["serve", "web", "attach"].includes(args[0]) && args.includes("--mini")); +} + export const HerdrAgentStatePlugin = async () => { if ( + !ownsLocalLifecycle() || process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID @@ -200,9 +215,8 @@ export const HerdrAgentStatePlugin = async () => { }; }; -// V1 (1.18.29+) calls server(). V2 calls setup() instead. Its shared server -// cannot attribute sessions using its process environment: the pane-local TUI -// owns both selection and lifecycle reporting there, including remote servers. +// V1 local run/Mini retain their server hooks. V1/V2 full TUIs own both +// selection and lifecycle, including when attached to a shared remote server. export default { id: "herdr.opencode", server: HerdrAgentStatePlugin, diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts index 38c1f05c..93d27583 100644 --- a/src/integration/assets/opencode/herdr-agent-state.test.ts +++ b/src/integration/assets/opencode/herdr-agent-state.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; + +const originalArgv = process.argv; +afterEach(() => { process.argv = originalArgv; }); const requests: unknown[] = []; const clients: FakeClient[] = []; @@ -43,6 +46,7 @@ beforeEach(() => { clients.length = 0; requestWaiters.length = 0; autoAcknowledge = true; + process.argv = ["bun", "/$bunfs/root/src/index.js", "run"]; process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = "test.sock"; process.env.HERDR_PANE_ID = "test:p1"; @@ -223,6 +227,27 @@ test("routes nested child prompts to their own root, not the last active root", ]); }); +test("only local run and Mini own server lifecycle, never shared servers or TUI workers", async () => { + for (const args of [ + ["run"], ["run", "--session", "existing"], ["--mini"], ["--mini", "--session", "existing"], + ["--print-logs", "--log-level", "DEBUG", "run"], ["run", "--", "--attach"], + ]) { + process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; + expect((await loadPlugin()).event).toBeFunction(); + } + for (const args of [ + [], ["--session", "existing"], ["serve"], ["web"], ["attach", "http://localhost:4096"], + ["run", "--attach", "http://localhost:4096"], ["--mini", "--attach=http://localhost:4096"], + ["serve", "--", "--mini"], + ]) { + process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; + expect(await loadPlugin()).toEqual({}); + } + process.argv = ["bun", "/$bunfs/root/src/cli/tui/worker.js"]; + expect(await loadPlugin()).toEqual({}); + expect(requests).toHaveLength(0); +}); + function requestMethod(request: unknown): unknown { return isRecord(request) ? request.method : undefined; } diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js index 4e5631ac..d21c1f7e 100644 --- a/src/integration/assets/opencode/herdr-tui-session.js +++ b/src/integration/assets/opencode/herdr-tui-session.js @@ -1,7 +1,7 @@ // installed by herdr // managed by herdr; reinstalling or updating the integration overwrites this file. // HERDR_INTEGRATION_ID=opencode-tui -// HERDR_INTEGRATION_VERSION=12 +// HERDR_INTEGRATION_VERSION=13 import net from "node:net"; @@ -67,65 +67,341 @@ export default { // Keep this plain object dependency-free: V1 and V2 expose different SDK // packages, but both loaders accept their own lifecycle entry on this object. setup, - tui: async (api) => { - if ( - process.env.HERDR_ENV !== "1" || - !process.env.HERDR_SOCKET_PATH || - !process.env.HERDR_PANE_ID - ) { - return; + tui, +}; + +async function tui(api) { + if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; + + let disposed = false; + let context; + let sequence = Date.now() * 1000; + let chain = Promise.resolve(); + + function routeID() { + const route = api.route.current; + return route?.name === "session" ? route.params?.sessionID : undefined; + } + + function current(ctx) { + return !disposed && context === ctx && routeID() === ctx.route; + } + + async function read(ctx, request) { + const result = await request({ + signal: AbortSignal.any([ctx.controller.signal, AbortSignal.timeout(5_000)]), + throwOnError: true, + }); + if (!current(ctx) || result?.data === undefined) throw new Error("session data unavailable"); + return result.data; + } + + // The first selected session owns this pane's subtree. Browsing descendants + // keeps that boundary; directly attaching to a child does not claim siblings. + function root(ctx, id) { + const seen = new Set(); + while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) { + seen.add(id); + if (id === ctx.boundary) return id; + const session = ctx.sessions.get(id) ?? api.state.session.get(id); + if (!session) return; + if (!session.parentID) return id; + id = session.parentID; } + } - let selectedSessionID; - let retryIndex = 0; - let nextReportAt = 0; - let reportPending = false; - const syncSelectedSession = async () => { - const route = api.route.current; - const sessionID = route?.name === "session" ? route.params?.sessionID : undefined; - const session = - typeof sessionID === "string" && sessionID - ? api.state.session.get(sessionID) - : undefined; - if (!session || session.parentID) { - selectedSessionID = undefined; - retryIndex = 0; - nextReportAt = 0; - return; + async function resolveRoot(ctx, id) { + const seen = new Set(); + while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) { + seen.add(id); + if (id === ctx.boundary) return id; + let session = ctx.sessions.get(id) ?? api.state.session.get(id); + if (!session) { + if (!ctx.lookups.has(id)) { + const requestedID = id; + const lookup = read(ctx, (options) => api.client.session.get({ sessionID: requestedID }, options)); + ctx.lookups.set(id, lookup); + lookup.finally(() => ctx.lookups.delete(requestedID)).catch(() => {}); + } + session = await ctx.lookups.get(id); + if (ctx.deleted.has(id)) return; + session = ctx.sessions.get(id) ?? session; + if (session.id !== id) throw new Error("unexpected session identity"); + ctx.sessions.set(id, session); } - if (sessionID !== selectedSessionID) { - selectedSessionID = sessionID; - retryIndex = 0; - nextReportAt = 0; + if (!session.parentID) return id; + id = session.parentID; + } + } + + function owners(ctx) { + return new Set([...ctx.statuses.keys(), ...[...ctx.blockers.values()].map((r) => r.sessionID)]); + } + + function state(ctx) { + if (ctx.settled) return "idle"; + for (const request of ctx.blockers.values()) { + if (root(ctx, request.sessionID) === ctx.selected) return "blocked"; + } + if (ctx.errors.has(ctx.selected)) return "blocked"; + for (const id of ctx.statuses.keys()) { + if (root(ctx, id) === ctx.selected) return "working"; + } + if (ctx.hydrated && [...owners(ctx)].every((id) => root(ctx, id) !== undefined)) return "idle"; + } + + function publish(ctx, selection = false) { + if (!current(ctx) || !ctx.selected) return; + ctx.selectionPending ||= selection; + if (ctx.queued) return; + ctx.queued = true; + chain = chain.then(async () => { + ctx.queued = false; + const selected = ctx.selected; + const isCurrent = () => current(ctx) && !!selected && ctx.selected === selected && + (ctx.settled || root(ctx, ctx.route) === selected); + if (!isCurrent()) return; + if (ctx.selectionPending) { + if (!await requestOnce(selected, undefined, undefined, isCurrent)) { + ctx.retryAt = Date.now() + 500; + return; + } + if (!isCurrent()) return; + ctx.selectionPending = false; + ctx.lastState = undefined; } - if (reportPending || Date.now() < nextReportAt) { - return; + const value = state(ctx); + if (value === undefined || value === ctx.lastState) return; + const delivered = await requestOnce(selected, value, ++sequence, isCurrent); + if (!isCurrent()) return; + ctx.lastState = delivered ? value : undefined; + if (!delivered) ctx.retryAt = Date.now() + 500; + }).catch(() => { + if (current(ctx)) { + ctx.lastState = undefined; + ctx.retryAt = Date.now() + 500; + } + }); + } + + function retire(ctx, route) { + ctx?.controller?.abort(); + context = ctx?.selected + ? { route, selected: ctx.selected, settled: true, retryAt: Infinity } + : undefined; + if (context) publish(context); + } + + function reconcile(ctx) { + if (!current(ctx)) return; + publish(ctx); + if (ctx.resolving) { + ctx.resolveAgain = true; + return; + } + ctx.resolving = true; + void (async () => { + const ancestor = await resolveRoot(ctx, ctx.route); + if (!current(ctx) || !ancestor) return; + const selected = ancestor === ctx.boundary ? ctx.boundary : ctx.route; + if (selected !== ctx.selected) { + ctx.boundary = selected; + ctx.selected = selected; + publish(ctx, true); } + // Fetch ancestry only for active/request-bearing sessions, not historical trees. + await Promise.all([...owners(ctx)].map((id) => resolveRoot(ctx, id))); + if (current(ctx)) publish(ctx); + })().catch(() => { + if (current(ctx)) ctx.retryAt = Date.now() + 500; + }).finally(() => { + ctx.resolving = false; + if (ctx.resolveAgain) { + ctx.resolveAgain = false; + reconcile(ctx); + } + }); + } - const reportingSessionID = sessionID; - reportPending = true; - try { - await requestOnce(reportingSessionID); - } catch { - // Best-effort reporting retries below while the selected route remains active. - } finally { - reportPending = false; + function clearRequests(ctx, matches) { + for (const [key, request] of ctx.blockers) if (matches(request)) ctx.blockers.delete(key); + } + + function terminalTool(part) { + return part?.type === "tool" && ["completed", "error"].includes(part.state?.status); + } + + function apply(ctx, event) { + const data = event.properties; + if (!data) return; + if (event.type === "message.part.updated") { + const part = data.part; + if (terminalTool(part)) { + clearRequests(ctx, (r) => r.sessionID === part.sessionID && + r.tool?.messageID === part.messageID && r.tool?.callID === part.callID); + } + return; + } + const id = data.sessionID ?? data.info?.id; + if (typeof id !== "string" || ctx.deleted.has(id)) return; + switch (event.type) { + case "session.created": + case "session.updated": + if (data.info) ctx.sessions.set(id, data.info); + break; + case "session.deleted": + ctx.deleted.add(id); + ctx.sessions.delete(id); + ctx.statuses.delete(id); + ctx.errors.delete(id); + clearRequests(ctx, (r) => r.sessionID === id); + if (ctx.selected === id) retire(ctx, ctx.route); + break; + case "session.status": + case "session.idle": { + const status = event.type === "session.idle" ? "idle" : data.status?.type; + if (status === "busy" || status === "retry") { + ctx.statuses.set(id, status); + ctx.errors.delete(id); + } else if (status === "idle") { + ctx.statuses.delete(id); + ctx.errors.delete(id); + } + break; } - if (selectedSessionID !== reportingSessionID) { - retryIndex = 0; - nextReportAt = 0; + case "session.error": + if (data.error?.name !== "MessageAbortedError") ctx.errors.add(id); + break; + case "permission.asked": + case "question.asked": + if (typeof data.id === "string") ctx.blockers.set(`${event.type.split(".")[0]}:${data.id}`, data); + break; + case "permission.replied": + case "question.replied": + case "question.rejected": + ctx.blockers.delete(`${event.type.split(".")[0]}:${data.requestID}`); + break; + } + } + + async function hydrate(ctx) { + if (!current(ctx) || ctx.loading) return; + ctx.loading = true; + try { + const [statuses, permissions, questions] = await Promise.all([ + read(ctx, (options) => api.client.session.status(undefined, options)), + read(ctx, (options) => api.client.permission.list(undefined, options)), + read(ctx, (options) => api.client.question.list(undefined, options)), + ]); + if (!statuses || typeof statuses !== "object" || Array.isArray(statuses) || + !Object.values(statuses).every((s) => ["busy", "retry", "idle"].includes(s?.type)) || + !Array.isArray(permissions) || !Array.isArray(questions)) { + throw new Error("incomplete session snapshot"); + } + const messages = new Map(); + const blockers = new Map(); + let validated = true; + await Promise.all([["permission", permissions], ["question", questions]].flatMap(([kind, requests]) => + requests.map(async (request) => { + if (ctx.deleted.has(request.sessionID)) return; + if (request.tool) { + const { messageID, callID } = request.tool; + const key = `${request.sessionID}:${messageID}`; + if (!messages.has(key)) messages.set(key, read(ctx, (options) => api.client.session.message({ + sessionID: request.sessionID, messageID, + }, options))); + try { + const message = await messages.get(key); + const part = message.parts?.find((p) => p.type === "tool" && p.callID === callID); + // OpenCode can retain pending requests after abort. Status alone is + // insufficient: the same session may already be running a new turn. + if (terminalTool(part)) return; + } catch { + validated = false; + } + } + if (!ctx.deleted.has(request.sessionID)) blockers.set(`${kind}:${request.id}`, request); + }))); + if (!current(ctx)) return; + ctx.statuses = new Map(Object.entries(statuses) + .filter(([id, status]) => !ctx.deleted.has(id) && (status.type === "busy" || status.type === "retry"))); + ctx.blockers = blockers; + // Preserve deltas until hydration succeeds, including retry backoff: + // a later snapshot must not revive a replied or cancelled request. + for (const event of ctx.events) apply(ctx, event); + ctx.hydrated = validated; + if (!validated) ctx.retryAt = Date.now() + 500; + reconcile(ctx); + } catch { + if (current(ctx)) ctx.retryAt = Date.now() + 500; + } finally { + ctx.loading = false; + if (ctx.hydrated) ctx.events = []; + } + } + + function syncSelection(reset = false) { + if (disposed) return; + const id = routeID(); + if (reset || id !== context?.route) { + if (typeof id !== "string" || !id) { + retire(context, id); return; } - const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex]; - retryIndex += 1; - nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay; - }; + const boundary = context?.settled ? undefined : context?.selected; + context?.controller?.abort(); + const ctx = { + route: id, boundary, controller: new AbortController(), sessions: new Map(), lookups: new Map(), + statuses: new Map(), blockers: new Map(), errors: new Set(), deleted: new Set(), + events: [], hydrated: false, loading: false, resolving: false, + retryAt: Infinity, selectionAt: 0, retryIndex: 0, + }; + context = ctx; + reconcile(ctx); + void hydrate(ctx); + } + const ctx = context; + if (!ctx) return; + if (!ctx.settled && ctx.selected && Date.now() >= ctx.selectionAt) { + publish(ctx, true); + const delay = SELECTION_RETRY_DELAYS_MS[ctx.retryIndex++]; + ctx.selectionAt = delay === undefined ? Infinity : Date.now() + delay; + } + if (Date.now() >= ctx.retryAt) { + ctx.retryAt = Infinity; + if (ctx.settled) publish(ctx); + else { + if (!ctx.hydrated) void hydrate(ctx); + reconcile(ctx); + } + } + } - await syncSelectedSession(); - const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS); - api.lifecycle.onDispose(() => clearInterval(routePoll)); - }, -}; + const subscriptions = [ + "session.created", "session.updated", "session.deleted", "session.status", "session.idle", "session.error", + "permission.asked", "permission.replied", "question.asked", "question.replied", "question.rejected", + "message.part.updated", + ].map((type) => api.event.on(type, (event) => { + if (type === "message.part.updated" && !terminalTool(event.properties?.part)) return; + syncSelection(); + const ctx = context; + if (!ctx || ctx.settled) return; + if (ctx.loading || !ctx.hydrated) ctx.events.push(event); + apply(ctx, event); + reconcile(ctx); + })); + for (const type of ["server.connected", "server.instance.disposed", "global.disposed"]) { + subscriptions.push(api.event.on(type, () => syncSelection(true))); + } + const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS); + api.lifecycle.onDispose(() => { + disposed = true; + context?.controller?.abort(); + clearInterval(poll); + for (const unsubscribe of subscriptions) unsubscribe(); + }); + syncSelection(); +} function setup(api) { if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode/herdr-tui-session.test.ts index dd031527..5ef5ce1a 100644 --- a/src/integration/assets/opencode/herdr-tui-session.test.ts +++ b/src/integration/assets/opencode/herdr-tui-session.test.ts @@ -70,12 +70,48 @@ async function loadPlugin() { function fakeApi() { const sessions = new Map<string, { id: string; parentID?: string }>(); + const statuses: Record<string, { type: string }> = {}; + const permissions: Array<{ id: string; sessionID: string; tool?: { messageID: string; callID: string } }> = []; + const questions: typeof permissions = []; + const messages = new Map<string, { info?: { error?: { name: string } }; parts: Array<object> }>(); + const listeners = new Map<string, Set<(event: object) => void>>(); + const calls: string[] = []; let current: { name: string; params?: { sessionID: string } } = { name: "home" }; let dispose: (() => void) | undefined; activeDisposers.push(() => dispose?.()); return { + statuses, permissions, questions, messages, listeners, calls, + emit(type: string, properties: object) { + for (const receive of listeners.get(type) ?? []) receive({ type, properties }); + }, api: { + client: { + session: { + async get({ sessionID }: { sessionID: string }) { + calls.push(`get:${sessionID}`); + const data = sessions.get(sessionID); + if (!data) throw new Error("session not found"); + return { data }; + }, + async status() { calls.push("status"); return { data: { ...statuses } }; }, + async message({ messageID }: { sessionID: string; messageID: string }) { + calls.push(`message:${messageID}`); + const data = messages.get(messageID); + if (!data) throw new Error("message unavailable"); + return { data }; + }, + }, + permission: { async list() { return { data: [...permissions] }; } }, + question: { async list() { return { data: [...questions] }; } }, + }, + event: { + on(type: string, receive: (event: object) => void) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(receive); + return () => listeners.get(type)!.delete(receive); + }, + }, route: { get current() { return current; @@ -101,6 +137,7 @@ function fakeApi() { select(sessionID: string) { current = { name: "session", params: { sessionID } }; }, + home() { current = { name: "home" }; }, dispose() { dispose?.(); }, @@ -140,10 +177,9 @@ test("retries an initial selection while Herdr detects the process", async () => await plugin.tui(tui.api); await new Promise((resolve) => setTimeout(resolve, 125)); - expect(requests.map((request) => requestParam(request, "agent_session_id"))).toEqual([ - "session-a", - "session-a", - ]); + const selections = requests.filter((request) => requestParam(request, "state") === undefined); + expect(selections.length).toBeGreaterThanOrEqual(2); + expect(selections.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe(true); }); test("does not report root sessions not selected by this TUI", async () => { @@ -169,13 +205,13 @@ test("does not replace the root session with a selected child session", async () tui.addSession({ id: "child-session", parentID: "root-session" }); tui.select("root-session"); await plugin.tui(tui.api); - expect(requests).toHaveLength(1); + await flushReports(); tui.select("child-session"); await new Promise((resolve) => setTimeout(resolve, 125)); - expect(requests).toHaveLength(1); - expect(requestParam(requests[0], "agent_session_id")).toBe("root-session"); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "root-session")).toBe(true); }); test("stops route polling when the TUI plugin is disposed", async () => { @@ -245,6 +281,274 @@ const flushReports = () => new Promise((resolve) => setTimeout(resolve, 10)); const states = () => requests.filter((r) => requestParam(r, "state") !== undefined) .map((r) => requestParam(r, "state")); +function familyApi() { + const tui = fakeApi(); + tui.addSession({ id: "root" }); + tui.addSession({ id: "child", parentID: "root" }); + tui.addSession({ id: "sibling", parentID: "root" }); + tui.addSession({ id: "grandchild", parentID: "child" }); + tui.addSession({ id: "other" }); + tui.select("root"); + return tui; +} + +test("V1 hydrates active descendants and keeps working until the whole family settles", async () => { + const tui = familyApi(); + tui.statuses.child = { type: "busy" }; + tui.statuses.grandchild = { type: "retry" }; + tui.statuses.other = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.status", { sessionID: "child", status: { type: "idle" } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.status", { sessionID: "grandchild", status: { type: "idle" } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "root")).toBe(true); +}); + +test("V1 retains sibling blockers and cancellation does not finish an active parent", async () => { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("permission.asked", { id: "p", sessionID: "child" }); + tui.emit("question.asked", { id: "q", sessionID: "sibling", tool: { messageID: "m", callID: "call" } }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("permission.replied", { requestID: "p", sessionID: "child" }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("session.idle", { sessionID: "sibling" }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "sibling", messageID: "m", callID: "call", state: { status: "error" }, + } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.idle", { sessionID: "root" }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 hydration rejects aborted tool requests even if their session is busy again", async () => { + for (const kind of ["permissions", "questions"] as const) { + const tui = familyApi(); + tui.statuses.child = { type: "busy" }; + tui[kind].push({ id: "stale", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { info: { error: { name: "MessageAbortedError" } }, parts: [ + { type: "tool", callID: "call", state: { status: "error" } }, + ] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.dispose(); + } +}); + +test("V1 validates pending tools instead of assuming an idle owner has no requests", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "pending", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" }, + } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 replays completion and replies over a late initial snapshot", async () => { + const tui = familyApi(); + let resolveStatus!: (result: { data: Record<string, { type: string }> }) => void; + tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; }); + tui.permissions.push({ id: "p", sessionID: "child" }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states()).not.toContain("idle"); + tui.emit("session.idle", { sessionID: "child" }); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + resolveStatus({ data: { child: { type: "busy" } } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 retains replies received between failed hydration and its retry", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child" }); + const status = tui.api.client.session.status; + tui.api.client.session.status = async () => { + tui.api.client.session.status = status; + throw new Error("temporarily unavailable"); + }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); + expect(states()).not.toContain("blocked"); +}); + +test("V1 deleted sessions cannot return through a late hydration snapshot", async () => { + const tui = familyApi(); + let resolveStatus!: (result: { data: Record<string, { type: string }> }) => void; + tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; }); + tui.permissions.push({ id: "p", sessionID: "child" }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("session.deleted", { info: { id: "child", parentID: "root" } }); + resolveStatus({ data: { child: { type: "busy" } } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 ignores stale hydration across A/B/A route changes and disposal", async () => { + const tui = familyApi(); + let resolveOld!: (result: { data: Record<string, { type: string }> }) => void; + const status = tui.api.client.session.status; + tui.api.client.session.status = () => { + tui.api.client.session.status = status; + return new Promise((resolve) => { resolveOld = resolve; }); + }; + await (await loadPlugin()).tui(tui.api); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + await flushReports(); + tui.select("root"); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + requests.length = 0; + resolveOld({ data: { root: { type: "busy" } } }); + await flushReports(); + expect(states()).not.toContain("working"); + tui.dispose(); + expect([...tui.listeners.values()].every((set) => set.size === 0)).toBe(true); +}); + +test("V1 a directly attached child owns its descendants, not its parent or siblings", async () => { + const tui = familyApi(); + tui.select("child"); + tui.statuses.root = { type: "busy" }; + tui.statuses.sibling = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("idle"); + tui.emit("session.status", { sessionID: "grandchild", status: { type: "busy" } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.select("grandchild"); + tui.emit("session.updated", { info: { id: "grandchild", parentID: "child" } }); + await flushReports(); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "child")).toBe(true); +}); + +test("V1 a terminal tool event during hydration cannot revive a cancelled blocker", async () => { + const tui = familyApi(); + tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + let resolveMessage!: (result: { data: { parts: Array<object> } }) => void; + tui.api.client.session.message = () => new Promise((resolve) => { resolveMessage = resolve; }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" }, + } }); + resolveMessage({ data: { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 unknown tool evidence remains blocked and retries failed message reads", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] }); + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 request validation matches the exact tool and deduplicates message reads", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "running" } }); + tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "finished" } }); + tui.messages.set("m", { parts: [ + { type: "tool", callID: "running", state: { status: "running" } }, + { type: "tool", callID: "finished", state: { status: "completed" } }, + ] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + expect(tui.calls.filter((call) => call === "message:m")).toHaveLength(1); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 reselecting an aborted request does not resurrect it or poll completed tools", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + await flushReports(); + tui.select("root"); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + expect(states()).not.toContain("blocked"); + const reads = tui.calls.length; + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(tui.calls).toHaveLength(reads); +}); + +test("V1 home and selected-session deletion settle authority and retry a dropped idle report", async () => { + for (const action of ["home", "delete"]) { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + failConnections = true; + if (action === "home") { + tui.home(); + tui.emit("session.updated", { info: { id: "root" } }); + } else tui.emit("session.deleted", { info: { id: "root" } }); + await flushReports(); + failConnections = false; + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); + tui.dispose(); + } +}); + +test("V1 a delayed home settlement cannot overwrite the next selected session", async () => { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + requests.length = 0; + holdConnections = true; + tui.home(); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + expect(connections.length).toBeGreaterThan(0); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + holdConnections = false; + for (const connect of connections.splice(0)) connect(); + await flushReports(); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "other")).toBe(true); + expect(states().at(-1)).toBe("idle"); +}); + test("V2 ignores events without data", async () => { const plugin = await loadPlugin(); const tui = v2Api(); diff --git a/src/integration/assets/opencode/tui.js b/src/integration/assets/opencode/tui.js index cc1ffe6a..92423a8f 100644 --- a/src/integration/assets/opencode/tui.js +++ b/src/integration/assets/opencode/tui.js @@ -1,5 +1,5 @@ // installed by herdr // HERDR_INTEGRATION_ID=opencode-tui-v2 -// HERDR_INTEGRATION_VERSION=12 +// HERDR_INTEGRATION_VERSION=13 // V2 resolves the directory's tui entrypoint; V1 uses the original file. export { default } from "../herdr-tui-session.js"; diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 349af16f..673225a3 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -184,7 +184,7 @@ const OPENCODE_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-tui- const OPENCODE_V2_TUI_PLUGIN_DIR: &str = "herdr-opencode"; const OPENCODE_V2_TUI_PLUGIN_SPEC: &str = "./herdr-opencode"; const OPENCODE_V2_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/tui.js"); -const OPENCODE_INTEGRATION_VERSION: u32 = 12; +const OPENCODE_INTEGRATION_VERSION: u32 = 13; const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js"); const KILO_INTEGRATION_VERSION: u32 = 4; From 90b0e40ab31bbdde8803f17d0140c6f4b436694a Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Tue, 22 Sep 2026 18:31:37 +0300 Subject: [PATCH 44/99] fix: preserve hook agent status across live handoff (#4493) --- .../next/website/src/content/docs/install.mdx | 2 + src/agent_resume.rs | 2 +- src/detect/mod.rs | 2 +- src/handoff_runtime.rs | 2 + src/pane.rs | 2 + src/persist/restore.rs | 110 ++++++++++++++++++ src/server/headless/lifecycle.rs | 6 + src/terminal/state.rs | 36 +++++- 8 files changed, 159 insertions(+), 3 deletions(-) diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx index c0c422a5..4dac5ede 100644 --- a/docs/next/website/src/content/docs/install.mdx +++ b/docs/next/website/src/content/docs/install.mdx @@ -147,6 +147,8 @@ To opt into experimental live server handoff for supported running sessions, run herdr update --handoff ``` +When both servers support agent-status transfer, live handoff preserves hook-reported agent status until the next report arrives. Handoff from an older server can temporarily show a running agent as idle. + Live handoff does not apply to Homebrew, mise, or Nix package-manager updates. For those installs, update with the package manager, then run Herdr again to reconnect with the updated client. The compatible old server keeps running. Restart it later with `herdr server stop` or `herdr session stop <name>` only when you want server-side changes from the new release. ## Requirements diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 04e6cf63..56554dd3 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; const MAX_SESSION_ID_LEN: usize = 512; const MAX_SESSION_PATH_LEN: usize = 4096; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSessionRef { pub kind: AgentSessionRefKind, pub value: String, diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 4ed32344..7fe15d11 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -7,7 +7,7 @@ pub mod manifest; pub mod manifest_update; /// The detected state of a terminal pane. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum AgentState { /// Agent finished, prompt visible, nothing happening. Idle, diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs index 29ba85f4..12481f6d 100644 --- a/src/handoff_runtime.rs +++ b/src/handoff_runtime.rs @@ -27,6 +27,8 @@ pub(crate) struct HandoffRuntimeState { pub terminal_title: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub initial_history_ansi: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_state: Option<crate::terminal::state::HandoffAgentState>, } #[cfg(unix)] diff --git a/src/pane.rs b/src/pane.rs index 9d75c21c..118dd3a4 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2088,6 +2088,7 @@ impl PaneRuntime { input_state: self.input_state(), terminal_title: self.terminal_title(), initial_history_ansi: None, + agent_state: None, } } @@ -2298,6 +2299,7 @@ impl PaneRuntime { input_state, terminal_title, initial_history_ansi, + agent_state: _, } = state; let pane_id = PaneId::from_raw(pane_id); use std::os::fd::FromRawFd; diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 31ee0a37..265b4874 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -561,6 +561,10 @@ fn restore_tab( .unwrap_or_default(); let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id)); let was_imported = imported_runtime.is_some(); + #[cfg(unix)] + let handoff_agent_state = imported_runtime + .as_ref() + .and_then(|imported| imported.state.agent_state.clone()); let pending_native_agent_restore = if was_imported { None } else { @@ -693,6 +697,10 @@ fn restore_tab( std::time::Instant::now(), ); } + #[cfg(unix)] + if let Some(agent_state) = handoff_agent_state { + terminal.restore_handoff_agent_state(agent_state); + } panes.insert(*id, PaneState::new(terminal_id.clone())); terminal_runtimes.insert(terminal_id, runtime); terminals.push(terminal); @@ -1708,6 +1716,108 @@ mod tests { ); } + #[tokio::test] + #[cfg(unix)] + async fn live_handoff_preserves_hook_status_until_next_report() { + for state_before_handoff in [AgentState::Working, AgentState::Blocked] { + let (snapshot, _) = snapshot_with_saved_pane_history(); + let (events, _events_rx) = mpsc::channel(32); + let (workspaces, mut terminals, runtimes) = restore( + &snapshot, + None, + 24, + 80, + 4096, + test_restore_shell(), + crate::config::ShellModeConfig::NonLogin, + false, + events.clone(), + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ); + let terminal = terminals.values_mut().next().unwrap(); + terminal + .set_detected_agent_process_at(crate::detect::Agent::Pi, std::time::Instant::now()); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:pi".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::path( + "/var/tmp/handoff-test.jsonl", + ) + .unwrap(), + }); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + state_before_handoff, + None, + Some( + crate::agent_resume::AgentSessionRef::path("/var/tmp/handoff-test.jsonl") + .unwrap(), + ), + Some(1), + ); + assert_eq!(terminal.state, state_before_handoff); + let runtimes = crate::terminal::TerminalRuntimeRegistry::from(runtimes); + let snapshot = crate::persist::capture(&workspaces, &terminals, &runtimes, Some(0), 0); + let pane_id = workspaces[0].tabs[0].panes.keys().next().copied().unwrap(); + let runtime = runtimes.values().next().unwrap(); + runtime + .pause_handoff_reader(std::time::Duration::from_secs(2)) + .unwrap(); + let mut state = runtime.handoff_runtime_state(pane_id.raw()); + state.agent_state = terminals.values().next().unwrap().handoff_agent_state(); + let state = serde_json::from_value(serde_json::to_value(state).unwrap()).unwrap(); + let mut imports = HashMap::from([( + pane_id.raw(), + crate::handoff_runtime::ImportedHandoffRuntime { + master_fd: runtime.duplicate_handoff_fd().unwrap(), + state, + }, + )]); + let (_, mut restored_terminals, restored_runtimes) = restore_handoff( + &snapshot, + 4096, + test_restore_shell(), + crate::config::ShellModeConfig::NonLogin, + &mut imports, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ) + .unwrap(); + drop(restored_runtimes); + drop(runtimes); + let terminal = restored_terminals.values_mut().next().unwrap(); + assert_eq!(terminal.state, state_before_handoff); + terminal.set_detected_state(Some(crate::detect::Agent::Pi), AgentState::Idle); + assert_eq!( + terminal.state, state_before_handoff, + "screen fallback must not erase the transferred hook status" + ); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + Some( + crate::agent_resume::AgentSessionRef::path("/var/tmp/handoff-test.jsonl") + .unwrap(), + ), + Some(2), + ); + assert_eq!( + terminal.state, + AgentState::Idle, + "the next hook report must take effect immediately" + ); + assert_eq!( + terminal.finish_agent_process_acquisition(), + state_before_handoff == AgentState::Blocked + ); + } + } + #[tokio::test] async fn restore_seeds_saved_pane_history_into_runtime() { let (snapshot, history) = snapshot_with_saved_pane_history(); diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs index f7ad87dd..c700a9d9 100644 --- a/src/server/headless/lifecycle.rs +++ b/src/server/headless/lifecycle.rs @@ -83,6 +83,12 @@ impl HeadlessServer { continue; }; let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); + handoff_runtime.agent_state = self + .app + .state + .terminals + .get(terminal_id) + .and_then(|terminal| terminal.handoff_agent_state()); let has_agent_session = self .app .state diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 119916c8..fadd6e10 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -14,16 +14,25 @@ use crate::terminal::TerminalId; mod metadata; pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct HookAuthority { pub source: String, pub agent_label: String, pub state: AgentState, pub message: Option<String>, + #[serde(skip, default = "Instant::now")] pub reported_at: Instant, pub session_ref: Option<crate::agent_resume::AgentSessionRef>, } +#[cfg(unix)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct HandoffAgentState { + authority: HookAuthority, + sequence: Option<u64>, + acquisition_pending: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SuppressedFullLifecycleHookReport { agent_label: String, @@ -213,6 +222,31 @@ impl TerminalState { mutation } + #[cfg(unix)] + pub(crate) fn handoff_agent_state(&self) -> Option<HandoffAgentState> { + if !self.live_full_lifecycle_hook_authority() { + return None; + } + let authority = self.hook_authority.as_ref()?; + Some(HandoffAgentState { + authority: authority.clone(), + sequence: self.hook_report_sequences.get(&authority.source).copied(), + acquisition_pending: self.agent_process_acquisition_pending, + }) + } + + #[cfg(unix)] + pub(crate) fn restore_handoff_agent_state(&mut self, snapshot: HandoffAgentState) { + if let Some(sequence) = snapshot.sequence { + self.hook_report_sequences + .insert(snapshot.authority.source.clone(), sequence); + } + self.detected_agent = crate::detect::parse_agent_label(&snapshot.authority.agent_label); + self.state = snapshot.authority.state; + self.hook_authority = Some(snapshot.authority); + self.agent_process_acquisition_pending = snapshot.acquisition_pending; + } + pub(crate) fn finish_agent_process_acquisition(&mut self) -> bool { let reached_idle = self.agent_process_acquisition_pending && self.state == AgentState::Idle; let suppress_completion = reached_idle && self.recent_agent_process_exit.is_none(); From 7237703dc59102cf3097e833eb896b314007b548 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Tue, 22 Sep 2026 17:48:23 +0200 Subject: [PATCH 45/99] fix: recognize codex mention completion popups (#4495) refs #4220 --- distribution/agent-detection/codex.toml | 5 +++-- src/detect/manifests/codex.toml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/distribution/agent-detection/codex.toml b/distribution/agent-detection/codex.toml index 8ceef314..ecef12b7 100644 --- a/distribution/agent-detection/codex.toml +++ b/distribution/agent-detection/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.15.1" +version = "2026.09.22.1" min_engine_version = 3 -updated_at = "2026-09-15T00:00:00Z" +updated_at = "2026-09-22T00:00: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]] diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index 8ceef314..ecef12b7 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.15.1" +version = "2026.09.22.1" min_engine_version = 3 -updated_at = "2026-09-15T00:00:00Z" +updated_at = "2026-09-22T00:00: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]] From 1c6397b1cddb288099ed212a5979732698bd72c2 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Tue, 22 Sep 2026 19:12:29 +0300 Subject: [PATCH 46/99] fix: reuse ssh authentication and add machine recovery commands (#4490) * fix: reuse ssh authentication and add in-app mfa recovery refs #3763 * refactor: move ssh recovery to machine cli refs #3763 --- .../src/content/docs/cli-reference.mdx | 2 +- .../src/content/docs/connecting-machines.mdx | 12 +- .../src/content/docs/ja/cli-reference.mdx | 2 +- .../content/docs/ja/connecting-machines.mdx | 12 +- .../src/content/docs/zh-cn/cli-reference.mdx | 2 +- .../docs/zh-cn/connecting-machines.mdx | 12 +- src/cli/machine.rs | 113 ++++++ src/cli/spec/machine.rs | 11 + src/cli/target.rs | 2 +- src/client/endpoint/supervisor.rs | 25 +- src/client/mod.rs | 1 + src/client/shell.rs | 1 + src/client/shell/composition.rs | 11 + src/client/shell/endpoint_sidebar.rs | 43 ++- src/client/shell/endpoints.rs | 8 + src/client/shell/input.rs | 3 + src/client/shell/machine_diagnostics.rs | 130 +++++++ src/client/shell/render.rs | 1 + src/client/shell/state.rs | 2 + src/client/shell/tests/endpoints.rs | 39 ++ src/client/shell_runtime.rs | 1 + src/platform/mod.rs | 15 +- src/platform/unix_common.rs | 136 +++++++ src/remote/attach.rs | 340 +++++++++++++++--- 24 files changed, 840 insertions(+), 84 deletions(-) create mode 100644 src/client/shell/machine_diagnostics.rs diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 12de68b7..52c39f40 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -64,7 +64,7 @@ By default, `machine add` uses the remote default session. Add `--remote-session Changes apply automatically to open local Herdr clients, normally within a second. Added or enabled machines connect in the background; renaming does not reconnect. Removing or disabling disconnects only that machine and leaves its remote sessions running. Removing the machine you are viewing returns to Local, or shows Local as unavailable until it reconnects. Each profile stores an opaque ID, label, SSH target, explicit remote session, and enabled state in client state. Herdr does not store passwords, private keys, or other SSH credentials. -Automatic connections and reconnects are non-interactive. If a host key, password, key passphrase, MFA step, install, update, or restart needs approval, the machine shows Attention instead of opening a hidden prompt. Run the standalone command printed by Herdr, such as `herdr --remote workbox`, to complete that setup in the foreground, then restart the client. Include `--session <name>` only when the profile targets a named session. +Use `herdr machine status [<label-or-id>] [--json]` for fresh, noninteractive SSH and remote Herdr checks. `reachable` describes this check, not a TUI's current connection. Use `herdr machine reconnect <label-or-id>` in a terminal for SSH authentication and verification; it does not install or update Herdr. Open clients retry failed connections within 30 seconds. Clicking `! auth` or `! error` shows the latest error and CLI guidance; collapsed sidebars show `!`. See [Recovering SSH authentication](/docs/connecting-machines/#recovering-ssh-authentication) for platform limits. For remote setup, use `herdr --remote workbox`, adding `--session <name>` only for a named session. Workspace, tab, pane IDs, and agent names belong to a single server. Selecting a machine in the UI does not retarget CLI commands. Use the global **prefix** `--machine <label-or-id>` to route API commands to a saved SSH machine: diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 8a7f5c7c..9be796f3 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -9,6 +9,12 @@ Each machine keeps its own Herdr server, sessions, and running processes. A lost Herdr requests SSH compression to reduce screen-update traffic on limited connections. This works with older compatible Herdr servers and does not change how typing or other input is delivered. Herdr's `-C` option overrides `Compression no` in your SSH config. If SSH reuses an existing connection through an external ControlMaster, that connection keeps its original compression setting. +### Recovering SSH authentication + +On Linux and macOS clients, when `remote.manage_ssh_config=true`, Herdr shares an approved OpenSSH connection across machine add, the sidebar, and saved-machine API commands. Run `herdr machine status [<label-or-id>] [--json]` for fresh, noninteractive checks. **Reachable** means the remote Herdr server is available now, not that every open TUI is connected. Run `herdr machine reconnect <label-or-id>` in a terminal to complete native SSH authentication and verify the saved machine. It does not install or update Herdr. Open clients recheck failed connections within 30 seconds; no restart is needed. Clicking **`! auth`**, **`! error`**, or a failed connection's reconnecting status shows a notice with its latest error and the relevant CLI command. It never opens a credential popup. The rest of the machine row keeps its normal expand/collapse behavior. + +The shared connection uses `ControlPersist 600` (10 minutes of idle time); this is not MFA validity. Active connections can remain open, while corporate policy, the SSH server, or the network may end them sooner. Local Windows recovery is not supported yet. For unmanaged SSH connections, authentication remains your responsibility: running `ssh` by itself does not grant Herdr approval unless it uses the shared ControlPath. Host-key checking is unchanged; an unknown or changed host key remains **Attention**. + ## Before you connect You need normal SSH access to the remote machine. Verify it first: @@ -76,18 +82,18 @@ Removing or disabling the machine you are viewing returns you to Local. If Local ## Connection problems - **Reconnecting:** Herdr retries automatically after a network interruption, sleep, or SSH failure. Repeated failures increase the delay up to two minutes; brief successful connections do not reset it. A connection must remain healthy for a minute before the next interruption gets a fast retry. SSH connections are checked for application-level activity and probed when quiet, so a broken connection does not stay Online indefinitely. Local detects native connection closure or failure instead of using remote health probes. -- **Attention:** The target needs an action that cannot be completed in the background, such as host-key approval, authentication, or a compatible server. Other machines remain usable. +- **Attention:** The target needs an action such as authentication, host-key approval, or a compatible server. Other machines remain usable. **`! auth`** means authentication failed; **`! error`** indicates another issue. Click the badge for the error and a CLI command. In a collapsed sidebar, either appears as `!`. - **Saved-machine file error:** An unreadable or invalid catalog leaves current connections unchanged. Herdr shows a notice and automatically retries reading it. When both installations support bridge idle cleanup, saved-machine connections to Linux and macOS also close their remote bridge after a minute without traffic in either direction. Sleep counts toward that deadline, which is checked when the host wakes. Quiet healthy connections exchange health checks; watching continuous output does not require typing. Cleanup leaves the remote Herdr server, sessions, and pane processes running. Older installations remain compatible without this optional cleanup. -Background connections never answer prompts or install, update, restart, or hand off a server. For Attention, run the standalone setup command shown by Herdr in an interactive terminal, for example: +Background connections never answer prompts or install, update, restart, or hand off a server. For non-authentication Attention, run the standalone setup command shown by Herdr in an interactive terminal, for example: ```bash herdr --remote workbox ``` -Use your profile's target. If you chose a named session when adding it, include the optional `--session <name>` here too. Follow any approval prompts, then restart the client to retry the Attention connection. Do not stop a running server merely because its version differs from the client. +Use your profile's target. If you chose a named session when adding it, include the optional `--session <name>` here too. Follow the requested setup prompts; open clients retry automatically. `machine reconnect` does not install or update Herdr. Do not stop a running server merely because its version differs from the client. If authentication fails, check ordinary SSH first. For a passphrase-protected key, load it with `ssh-add` before starting Herdr's non-interactive background connections. diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 99a2e133..81894daa 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -62,7 +62,7 @@ herdr machine remove <profile-id> 変更は、開いているローカルの Herdr クライアントに通常 1 秒以内に自動反映されます。追加または有効化したマシンにはバックグラウンドで接続し、名前の変更では再接続しません。削除または無効化すると、そのマシンだけが切断され、リモートセッションは動き続けます。表示中のマシンを削除すると Local に戻ります。Local に接続できない場合は、再接続するまで利用不可と表示します。各プロファイルは、不透明な ID、ラベル、SSH 接続先、明示的なリモートセッション名、有効状態をクライアントの状態ファイルに保存します。パスワード、秘密鍵、その他の SSH 認証情報は保存しません。 -自動接続と再接続は非対話型です。ホスト鍵、パスワード、鍵のパスフレーズ、MFA、インストール、更新、再起動に承認が必要な場合、隠れたプロンプトを開くのではなく Attention と表示します。`herdr --remote workbox` など、Herdr が表示する単独接続用のコマンドを実行してフォアグラウンドでセットアップを完了し、クライアントを再起動してください。名前付きセッションを選んだプロファイルにだけ `--session <name>` を追加します。 +`herdr machine status [<label-or-id>] [--json]` は SSH とリモート Herdr を非対話式で確認します。`reachable` は今回の確認結果で、TUI の接続状態ではありません。ターミナルで `herdr machine reconnect <label-or-id>` を実行すると認証と検証を行いますが、インストールや更新は行いません。開いているクライアントは 30 秒以内に再試行します。`! auth` または `! error` をクリックすると最新のエラーと CLI コマンドを表示します。折りたたみ時は `!` です。対応環境は [SSH 認証の復旧](/ja/docs/connecting-machines/#ssh-認証の復旧)を参照してください。セットアップには `herdr --remote workbox` を使い、名前付きセッションの場合だけ `--session <name>` を追加します。 ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。UI での選択は CLI の接続先を変えません。保存済み SSH マシンへ API コマンドを送るには、グローバルな**プレフィックス** `--machine <label-or-id>` を指定します。 diff --git a/docs/next/website/src/content/docs/ja/connecting-machines.mdx b/docs/next/website/src/content/docs/ja/connecting-machines.mdx index 2c3a41cc..ef8bb5bc 100644 --- a/docs/next/website/src/content/docs/ja/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/ja/connecting-machines.mdx @@ -7,6 +7,12 @@ description: Local と保存済み SSH マシンを 1 つの Herdr ウィンド 各マシンは独自の Herdr サーバー、セッション、プロセスを保持します。1 台への接続が切れても、ほかの接続は切れません。 +### SSH 認証の復旧 + +Linux と macOS で `remote.manage_ssh_config=true` の場合、マシン追加、サイドバー、API 操作は承認済み OpenSSH 接続を共有します。`herdr machine status [<label-or-id>] [--json]` は認証を求めず、現在の接続状態を確認します。**reachable** はリモート Herdr が利用可能という意味で、各 TUI の接続状態ではありません。ターミナルで `herdr machine reconnect <label-or-id>` を実行すると、SSH 認証後に保存済みマシンを検証します。インストールや更新は行いません。開いているクライアントは 30 秒以内に再試行し、再起動は不要です。**`! auth`**、**`! error`**、失敗後の再接続ステータスをクリックすると、最新のエラーと CLI コマンドが通知に表示されます。認証ポップアップは開きません。マシン行の展開・折りたたみは変更されません。 + +共有接続の `ControlPersist 600` はアイドル状態の 10 分を表すもので、MFA の有効期間ではありません。通信中の接続は維持されますが、企業ポリシー、SSH サーバー、ネットワークによってはそれより早く終了します。ローカル Windows の復旧はまだ対応していません。管理対象外の SSH 接続の認証は利用者の責任です。共有 ControlPath を使わずに `ssh` を実行するだけでは、Herdr の承認にはなりません。ホスト鍵の検証は緩和されず、未知または変更されたホスト鍵は引き続き **Attention** です。 + ## 接続前の確認 リモートマシンへの通常の SSH アクセスが必要です。最初に確認してください: @@ -74,18 +80,18 @@ herdr machine remove <profile-id> ## 接続の問題 - **Reconnecting:** ネットワーク中断、スリープ、SSH 障害の後は自動で再試行します。失敗が続くと待機間隔は最大 2 分まで延び、短時間だけ接続に成功してもリセットされません。1 分間安定して接続できると、次の中断では再び短い間隔で再試行します。SSH 接続はアプリケーションの通信を監視し、通信がない間はヘルスチェックを行うため、切れた接続が Online のままになりません。Local はリモート用のプローブではなく、ネイティブ接続の終了や失敗を検知します。 -- **Attention:** ホスト鍵の承認、認証、互換サーバーの準備など、バックグラウンドではできない操作が必要です。ほかのマシンは利用できます。 +- **Attention:** 認証、ホスト鍵の承認、互換サーバーの準備などが必要です。ほかのマシンは利用できます。**`! auth`** は認証失敗、**`! error`** は別の問題を示します。バッジをクリックするとエラーと CLI コマンドを表示します。折りたたみ時はどちらも `!` です。 - **保存済みマシンのファイルエラー:** カタログを読み取れない、または内容が不正な場合、現在の接続は変更しません。通知を表示して自動で読み取りを再試行します。 両端のインストールがブリッジのアイドル終了に対応している場合、Linux と macOS への保存済みマシン接続は、どちらの方向にも通信がない状態が 1 分続くとリモートブリッジを終了します。スリープ時間も含み、復帰時に期限を確認します。正常なアイドル接続ではヘルスチェックを交換するため、継続的な出力を見るためにキーを入力する必要はありません。ブリッジ終了後もリモートサーバー、セッション、ペインのプロセスは動き続けます。古いインストールとは、この追加機能なしで接続できます。 -バックグラウンド接続がプロンプトに回答したり、サーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。Attention の場合は、Herdr が表示する単独接続用のコマンドを対話型ターミナルで実行してください。例: +バックグラウンド接続がプロンプトに回答したり、サーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。認証以外の Attention の場合は、Herdr が表示する単独接続用のコマンドを対話型ターミナルで実行してください。例: ```bash herdr --remote workbox ``` -プロファイルの接続先を使います。追加時に名前付きセッションを選んだ場合だけ、`--session <name>` も指定します。必要な確認に回答したら、クライアントを再起動して Attention の接続を再試行してください。バージョンが違うという理由だけで実行中サーバーを止めないでください。 +プロファイルの接続先を使います。名前付きセッションを選んだ場合だけ `--session <name>` も指定します。セットアップ後、開いているクライアントは自動的に再試行します。`machine reconnect` は Herdr をインストール・更新しません。バージョンが違うという理由だけで実行中サーバーを止めないでください。 認証に失敗したら、まず通常の SSH を確認します。パスフレーズ付きの鍵は、非対話型のバックグラウンド接続を始める前に `ssh-add` で読み込んでください。 diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 011efbd3..080abb48 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -62,7 +62,7 @@ herdr machine remove <profile-id> 更改会自动应用到已打开的本地 Herdr 客户端,通常在一秒内生效。新增或启用的机器在后台连接;重命名不会重新连接。移除或禁用只会断开该机器,远程会话仍会继续运行。移除正在查看的机器会返回 Local;如果 Local 不可用,则显示不可用状态,直到重新连接。每份配置在客户端状态文件中保存一个不透明 ID、标签、SSH 目标、明确的远程会话名和启用状态。Herdr 不保存密码、私钥或其他 SSH 凭据。 -自动连接和重连均为非交互式。如果主机密钥、密码、密钥口令、MFA、安装、更新或重启需要批准,机器会显示 Attention,而不会打开隐藏的提示。请运行 Herdr 显示的独立连接命令,例如 `herdr --remote workbox`,在前台完成设置后重启客户端。仅当配置指向命名会话时,才加上 `--session <name>`。 +`herdr machine status [<label-or-id>] [--json]` 可非交互式检查 SSH 和远程 Herdr。`reachable` 表示本次检查成功,不代表 TUI 当前已连接。在终端运行 `herdr machine reconnect <label-or-id>` 可认证并验证连接,不会安装或更新 Herdr。已打开的客户端会在 30 秒内重试。点击 `! auth` 或 `! error` 可查看最新错误和 CLI 命令;侧边栏折叠时显示 `!`。平台限制请参阅[恢复 SSH 认证](/zh-cn/docs/connecting-machines/#恢复-ssh-认证)。需要远程设置时使用 `herdr --remote workbox`,仅在命名会话时添加 `--session <name>`。 工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。UI 中的选择不会改变 CLI 的目标。使用全局**前缀** `--machine <label-or-id>` 将 API 命令发送到已保存的 SSH 机器: diff --git a/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx b/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx index 4175d5ee..722adeed 100644 --- a/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx @@ -7,6 +7,12 @@ description: 在一个 Herdr 窗口中使用 Local 和已保存的 SSH 机器, 每台机器保留自己的 Herdr 服务器、会话和进程。一台机器断线不会影响其他连接。 +### 恢复 SSH 认证 + +在 Linux 和 macOS 上,启用 `remote.manage_ssh_config=true` 后,添加机器、侧边栏和 API 操作共享已批准的 OpenSSH 连接。运行 `herdr machine status [<label-or-id>] [--json]` 可进行新的非交互式检查。**reachable** 表示远程 Herdr 当前可用,不代表每个 TUI 都已连接。在终端运行 `herdr machine reconnect <label-or-id>` 完成原生 SSH 认证并验证已保存的机器;该命令不会安装或更新 Herdr。已打开的客户端会在 30 秒内重试,无需重启。点击 **`! auth`**、**`! error`** 或失败后的重连状态,会显示包含最新错误及 CLI 命令的通知,不会打开认证弹窗。机器行其余部分仍用于展开或折叠。 + +共享连接使用 `ControlPersist 600`,表示空闲 10 分钟,并不表示 MFA 有效期。活动连接可以继续保持,但企业策略、SSH 服务器或网络可能更早终止连接。Windows 本地恢复尚未支持。未受管理的 SSH 连接由你负责认证;单独运行 `ssh` 不会授予 Herdr 批准,除非它使用共享 ControlPath。主机密钥检查不会放宽;未知或已更改的主机密钥仍显示为 **Attention**。 + ## 连接前的准备 你需要能够通过普通 SSH 访问远程机器。先验证: @@ -74,18 +80,18 @@ herdr machine remove <profile-id> ## 连接问题 - **Reconnecting:** 网络中断、睡眠或 SSH 故障后,Herdr 会自动重试。连续失败会将间隔增加到最多两分钟,短暂连接成功不会重置间隔。连接稳定一分钟后,下一次中断才会恢复快速重试。SSH 连接会监测应用层活动并在空闲时探测,避免已断开的连接一直显示 Online。Local 使用本地连接关闭或失败来判断断线,而不是远程健康探测。 -- **Attention:** 目标需要无法在后台完成的操作,例如批准主机密钥、认证或准备兼容服务器。其他机器仍可使用。 +- **Attention:** 目标需要认证、批准主机密钥或准备兼容服务器等操作。其他机器仍可使用。**`! auth`** 表示认证失败,**`! error`** 表示其他问题。点击徽章可查看错误和 CLI 命令。侧边栏折叠时两者都显示为 `!`。 - **保存机器文件错误:** 目录文件无法读取或内容无效时,当前连接保持不变。Herdr 会显示通知并自动重试读取。 当两端都支持空闲桥接清理时,连接 Linux/macOS 的保存机器会在双向均无流量一分钟后关闭远程桥接。睡眠时间也计入期限,并在唤醒时检查。正常的空闲连接会交换健康检查,持续查看输出无需键盘输入。清理不会停止远程 Herdr 服务器、会话或窗格进程。旧安装仍可兼容连接,只是不使用这项可选清理功能。 -后台连接不会回答提示,也不会安装、更新、重启或交接服务器。对于 Attention,请在交互式终端运行 Herdr 显示的独立设置命令,例如: +后台连接不会回答提示,也不会安装、更新、重启或交接服务器。对于非认证类 Attention,请在交互式终端运行 Herdr 显示的独立设置命令,例如: ```bash herdr --remote workbox ``` -使用配置中的目标。如果添加时选择了命名会话,再加上可选的 `--session <name>`。完成批准提示后,重启客户端以重试 Attention 连接。不要仅因为版本与客户端不同就停止运行中的服务器。 +使用配置中的目标。如果添加时选择了命名会话,再加上 `--session <name>`。完成设置后,已打开的客户端会自动重试。`machine reconnect` 不会安装或更新 Herdr。不要仅因为版本与客户端不同就停止运行中的服务器。 认证失败时,先检查普通 SSH。对于有口令的私钥,请在启动 Herdr 的非交互式后台连接前用 `ssh-add` 加载。 diff --git a/src/cli/machine.rs b/src/cli/machine.rs index 53404c03..9328347e 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -4,6 +4,8 @@ use crate::client::endpoint::{EndpointCatalog, ProfileId}; const HELP: &str = "Usage: herdr machine list [--json] + herdr machine status [<label-or-id>] [--json] + herdr machine reconnect <label-or-id> herdr machine add <ssh-target> --label <label> [--remote-session <name>] herdr machine rename <profile-id> --label <label> herdr machine remove <profile-id> @@ -30,6 +32,8 @@ struct MachineListRow<'a> { pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> { match args.first().map(String::as_str) { Some("list") => list(&args[1..]), + Some("status") => status(&args[1..]), + Some("reconnect") => reconnect(&args[1..]), Some("add") => add(&args[1..]), Some("rename") => rename(&args[1..]), Some("remove") => remove(&args[1..]), @@ -89,6 +93,115 @@ fn list(args: &[String]) -> std::io::Result<i32> { Ok(0) } +#[derive(Serialize)] +struct MachineStatusRow<'a> { + id: &'a str, + label: &'a str, + status: &'static str, + error: Option<String>, +} + +fn status(args: &[String]) -> std::io::Result<i32> { + let mut json = false; + let mut selector = None; + for arg in args { + if arg == "--json" && !json { + json = true; + } else if !arg.starts_with('-') && selector.is_none() { + selector = Some(arg.as_str()); + } else { + eprintln!("usage: herdr machine status [<label-or-id>] [--json]"); + return Ok(2); + } + } + let catalog = load_catalog()?; + let profiles = match selector { + Some(selector) => match super::target::resolve_machine(&catalog.ssh, selector) { + Ok(profile) => vec![profile], + Err(error) => { + eprintln!("{error}"); + return Ok(2); + } + }, + None => catalog.ssh.iter().collect(), + }; + let rows = profiles + .into_iter() + .map(|profile| { + let (status, error) = if !profile.enabled { + ("disabled", None) + } else { + match crate::remote::check_saved_ssh(&profile.target, &profile.session) { + Ok(()) => ("reachable", None), + Err(error) => { + let message = error.to_string(); + let status = if crate::remote::ssh_error_requires_authentication(&message) { + "auth required" + } else { + "error" + }; + (status, Some(message)) + } + } + }; + MachineStatusRow { + id: profile.id.as_str(), + label: &profile.label, + status, + error, + } + }) + .collect::<Vec<_>>(); + if json { + println!( + "{}", + serde_json::to_string_pretty(&rows).map_err(std::io::Error::other)? + ); + } else { + for row in &rows { + println!("{}\t{}\t{}", row.id, row.label, row.status); + if let Some(error) = &row.error { + println!(" {}", error.escape_debug()); + } + } + if rows.is_empty() { + println!("No saved SSH machines."); + } + } + Ok(i32::from(rows.iter().any(|row| row.error.is_some()))) +} + +fn reconnect(args: &[String]) -> std::io::Result<i32> { + use std::io::IsTerminal; + let [selector] = args else { + eprintln!("usage: herdr machine reconnect <label-or-id>"); + return Ok(2); + }; + let catalog = load_catalog()?; + let profile = match super::target::resolve_machine(&catalog.ssh, selector) { + Ok(profile) => profile, + Err(error) => { + eprintln!("{error}"); + return Ok(2); + } + }; + if !std::io::stdin().is_terminal() { + eprintln!("reconnect requires an interactive terminal; use herdr machine status for noninteractive checks"); + return Ok(2); + } + let mut authentication = crate::remote::ssh_authentication_command(&profile.target)?; + if !authentication.command.status()?.success() { + eprintln!("SSH authentication failed; the saved machine was not changed."); + return Ok(1); + } + crate::remote::check_saved_ssh(&profile.target, &profile.session)?; + println!( + "Machine {} is reachable. Open Herdr clients retry within 30 seconds.", + profile.id + ); + Ok(0) +} + #[derive(Debug, PartialEq, Eq)] struct AddArgs { target: String, diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs index 188e27b9..82bc4c5d 100644 --- a/src/cli/spec/machine.rs +++ b/src/cli/spec/machine.rs @@ -10,6 +10,17 @@ pub(super) fn command() -> Command { .about("List saved SSH machines") .arg(json_flag()), ) + .subcommand( + Command::new("status") + .about("Check saved machines without prompting for authentication") + .arg(Arg::new("machine").value_name("LABEL_OR_ID")) + .arg(json_flag()), + ) + .subcommand( + Command::new("reconnect") + .about("Authenticate a saved machine in this terminal and verify connectivity") + .arg(Arg::new("machine").value_name("LABEL_OR_ID").required(true)), + ) .subcommand( Command::new("add") .about("Prepare the remote Herdr server and save an SSH machine") diff --git a/src/cli/target.rs b/src/cli/target.rs index 220410f9..9c8b9c80 100644 --- a/src/cli/target.rs +++ b/src/cli/target.rs @@ -257,7 +257,7 @@ fn parse_machine_prefix(args: &[String]) -> Result<Option<(String, Vec<String>)> Ok(Some((machine, cleaned))) } -fn resolve_machine<'a>( +pub(super) fn resolve_machine<'a>( profiles: &'a [SavedSshEndpoint], selector: &str, ) -> Result<&'a SavedSshEndpoint, String> { diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs index a5154156..ff86ac77 100644 --- a/src/client/endpoint/supervisor.rs +++ b/src/client/endpoint/supervisor.rs @@ -210,7 +210,13 @@ impl EndpointSupervisors { state.online_since.get_or_insert(now); state.next_attempt = None; } - ClientEndpointStatus::Attention | ClientEndpointStatus::Disabled => { + ClientEndpointStatus::Attention => { + state.online_since = None; + // Authentication or configuration may be repaired outside this client. + state.next_attempt = + (!endpoint_id.is_local()).then_some(now + Duration::from_secs(30)); + } + ClientEndpointStatus::Disabled => { state.online_since = None; state.next_attempt = None; } @@ -278,11 +284,11 @@ fn connect_once( (stream, Box::new(())) } ConnectTarget::Ssh(profile) => { - let connected = crate::remote::connect_saved_ssh(profile.id.as_str(), &profile.target, &profile.session).map_err(|error| { - if failure_needs_attention(&error) { - std::io::Error::new(error.kind(), format!("{error}. Run `{}` interactively to approve setup, then restart this client", crate::remote::saved_ssh_bootstrap_command(&profile.target, &profile.session))) - } else { error } - })?; + let connected = crate::remote::connect_saved_ssh( + profile.id.as_str(), + &profile.target, + &profile.session, + )?; (connected.stream, Box::new(connected.bridge)) } }; @@ -517,7 +523,7 @@ mod tests { } #[test] - fn ssh_recovery_rejects_stale_generations_and_stops_retries_for_attention() { + fn ssh_recovery_rejects_stale_generations_and_rechecks_attention() { let now = Instant::now(); let mut supervisors = EndpointSupervisors::new(&[profile()], now); let endpoint_id = ClientEndpointId::Ssh(profile().id); @@ -535,6 +541,9 @@ mod tests { Some(now + INITIAL_RETRY_DELAY) ); assert!(supervisors.record_status(&endpoint_id, 4, ClientEndpointStatus::Attention, now)); - assert!(supervisors.endpoints[&endpoint_id].next_attempt.is_none()); + assert_eq!( + supervisors.endpoints[&endpoint_id].next_attempt, + Some(now + Duration::from_secs(30)) + ); } } diff --git a/src/client/mod.rs b/src/client/mod.rs index 68e569f9..9ad51a36 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1184,6 +1184,7 @@ async fn run_client_loop( } let unavailable = state.shell.as_mut().and_then(|shell| { shell.set_endpoint_status(&endpoint_id, status); + shell.set_machine_diagnostic(&endpoint_id, message.clone()); (status == endpoint::ClientEndpointStatus::Attention && shell.endpoint_is_active(&endpoint_id)) .then(|| format!("{}: {message}", shell.endpoint_label(&endpoint_id))) diff --git a/src/client/shell.rs b/src/client/shell.rs index 27d30df9..2d180443 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; mod actions; mod agent_sidebar; mod aggregate_navigation; +mod machine_diagnostics; mod workspace_navigation; use workspace_navigation::{PendingWorkspaceHighlight, WorkspaceNavigationTarget}; mod composition; diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 4da21e77..9b710dd5 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -55,6 +55,7 @@ impl ClientShellState { == Some(ClientEndpointStatus::Online) }); let mut render_state = render::ShellRenderState { + machine_diagnostics: &self.machine_diagnostics, endpoints: &self.endpoints, active_endpoint_id: &self.active_endpoint_id, collapsed_endpoints: &self.collapsed_endpoints, @@ -134,6 +135,15 @@ impl ClientShellState { &self.config.keybinds, &self.config.palette, ); + if let Some(notice) = &self.visible_endpoint_notice { + self.hits.notification_toast = endpoint_notices::render_notice( + &mut buffer, + Rect::new(0, 0, cols, rows), + notice, + 1, + &self.config.palette, + ); + } FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]) } @@ -197,6 +207,7 @@ impl ClientShellState { snapshot, &self.config, render::ShellRenderState { + machine_diagnostics: &self.machine_diagnostics, endpoints: &self.endpoints, active_endpoint_id: &self.active_endpoint_id, collapsed_endpoints: &self.collapsed_endpoints, diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs index c4f701c3..96982487 100644 --- a/src/client/shell/endpoint_sidebar.rs +++ b/src/client/shell/endpoint_sidebar.rs @@ -90,12 +90,26 @@ pub(super) fn render_collapsed( palette.overlay0 }), ); + let mut status_badge = Rect::default(); if !endpoint.endpoint_id.is_local() { let (glyph, _, color) = endpoint_status_presentation(endpoint.status, palette); - put_right_text(buffer, rect, rect.y, glyph, Style::default().fg(color)); + let width = display_width(glyph).min(rect.width); + status_badge = Rect::new(rect.right().saturating_sub(width), rect.y, width, 1); + put_right_text( + buffer, + rect, + rect.y, + glyph, + state.machine_diagnostics.badge_style( + endpoint, + palette, + Style::default().fg(color), + ), + ); } hits.machines.push(MachineHit { rect, + status_badge, collapse_toggle: Rect::new(rect.x, rect.y, u16::from(rect.width > 1), 1), endpoint_id: endpoint.endpoint_id.clone(), }); @@ -394,16 +408,18 @@ pub(super) fn render_expanded( let rect = Rect::new(body.x, y, content_width, 1); let collapsed = state.collapsed_endpoints.contains(&endpoint.endpoint_id); let marker = if collapsed { "▸" } else { "▾" }; - render_endpoint_row( + let status_badge = render_endpoint_row( buffer, rect, marker, endpoint, collapsed && &endpoint.endpoint_id == state.active_endpoint_id, + state.machine_diagnostics, palette, ); hits.machines.push(MachineHit { rect, + status_badge, collapse_toggle: Rect::new( rect.x.saturating_add(1), rect.y, @@ -577,8 +593,9 @@ fn render_endpoint_row( marker: &str, endpoint: &ClientShellEndpoint, highlighted: bool, + auth: &super::machine_diagnostics::MachineDiagnostics, palette: &Palette, -) { +) -> Rect { if highlighted { buffer.set_style(rect, Style::default().bg(palette.active_row_bg)); } @@ -588,7 +605,11 @@ fn render_endpoint_row( } else { state }; - let signal = if endpoint.endpoint_id.is_local() { + let signal = if auth.required_for(endpoint) { + "! auth".to_owned() + } else if endpoint.status == ClientEndpointStatus::Attention { + "! error".to_owned() + } else if endpoint.endpoint_id.is_local() { String::new() } else if state.is_empty() { glyph.to_owned() @@ -612,5 +633,17 @@ fn render_endpoint_row( ) .add_modifier(Modifier::BOLD), ); - put_right_text(buffer, rect, rect.y, &signal, Style::default().fg(color)); + put_right_text( + buffer, + rect, + rect.y, + &signal, + auth.badge_style(endpoint, palette, Style::default().fg(color)), + ); + Rect::new( + rect.right().saturating_sub(signal_width), + rect.y, + signal_width, + 1, + ) } diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index dd6fa3cb..fff28b71 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -26,6 +26,7 @@ pub(crate) struct ClientShellEndpoint { pub(super) struct MachineHit { pub(super) rect: Rect, + pub(super) status_badge: Rect, pub(super) collapse_toggle: Rect, pub(super) endpoint_id: ClientEndpointId, } @@ -108,6 +109,7 @@ impl ClientShellState { } pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) { + self.clear_machine_diagnostic(endpoint_id); if endpoint_id == &self.active_endpoint_id { self.pending_workspace_highlight = None; } @@ -134,6 +136,12 @@ impl ClientShellState { endpoint_id: &ClientEndpointId, status: ClientEndpointStatus, ) { + if matches!( + status, + ClientEndpointStatus::Online | ClientEndpointStatus::Disabled + ) { + self.clear_machine_diagnostic(endpoint_id); + } if endpoint_id == &self.active_endpoint_id && status != ClientEndpointStatus::Online { self.pending_workspace_highlight = None; } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index f1e7ea25..0e14e18c 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -163,6 +163,9 @@ impl ClientShellState { outcome.repaint = true; } for event in events { + if self.handle_machine_badge_event(&event, &mut outcome) { + continue; + } if let Some(update) = host_theme_update(&event) { push_host_theme_update(&mut outcome.requests, update); } diff --git a/src/client/shell/machine_diagnostics.rs b/src/client/shell/machine_diagnostics.rs new file mode 100644 index 00000000..67983024 --- /dev/null +++ b/src/client/shell/machine_diagnostics.rs @@ -0,0 +1,130 @@ +use super::*; +use crate::raw_input::RawInputEvent; +use crossterm::event::{MouseButton, MouseEventKind}; + +#[derive(Default)] +pub(super) struct MachineDiagnostics { + errors: HashMap<ClientEndpointId, String>, + hover: Option<ClientEndpointId>, +} + +impl MachineDiagnostics { + pub(super) fn required_for(&self, endpoint: &ClientShellEndpoint) -> bool { + self.errors + .get(&endpoint.endpoint_id) + .is_some_and(|message| crate::remote::ssh_error_requires_authentication(message)) + } + + pub(super) fn badge_style( + &self, + endpoint: &ClientShellEndpoint, + palette: &Palette, + style: Style, + ) -> Style { + if self.hover.as_ref() == Some(&endpoint.endpoint_id) + && self.errors.contains_key(&endpoint.endpoint_id) + { + style + .bg(palette.active_row_bg) + .add_modifier(Modifier::REVERSED) + } else { + style + } + } +} + +impl ClientShellState { + pub(crate) fn set_machine_diagnostic(&mut self, id: &ClientEndpointId, message: String) { + if !id.is_local() { + self.machine_diagnostics.errors.insert( + id.clone(), + message + .chars() + .filter(|c| !c.is_control() || *c == '\n') + .take(4096) + .collect(), + ); + } + } + + pub(super) fn clear_machine_diagnostic(&mut self, id: &ClientEndpointId) { + self.machine_diagnostics.errors.remove(id); + } + + pub(super) fn handle_machine_badge_event( + &mut self, + event: &RawInputEvent, + outcome: &mut ClientShellInput, + ) -> bool { + let RawInputEvent::Mouse(mouse) = event else { + return false; + }; + if self.overlay.is_some() + || self.popup_pending + || self.hits.popup.is_some() + || contains(self.hits.notification_toast, (mouse.column, mouse.row)) + { + return false; + } + let hit = self + .hits + .machines + .iter() + .find(|hit| { + contains(hit.status_badge, (mouse.column, mouse.row)) + && self + .machine_diagnostics + .errors + .contains_key(&hit.endpoint_id) + }) + .map(|hit| hit.endpoint_id.clone()); + if mouse.kind == MouseEventKind::Moved { + if self.machine_diagnostics.hover != hit { + self.machine_diagnostics.hover = hit; + outcome.repaint = true; + } + return false; + } + let Some(id) = hit else { + return false; + }; + if mouse.kind == MouseEventKind::Up(MouseButton::Left) { + return true; + } + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return false; + } + let Some(error) = self.machine_diagnostics.errors.get(&id) else { + return true; + }; + let ClientEndpointId::Ssh(profile_id) = &id else { + return true; + }; + let command = if crate::remote::ssh_error_requires_authentication(error) { + format!("herdr machine reconnect {profile_id}") + } else { + format!("herdr machine status {profile_id}") + }; + let code = format!("machine-diagnostic:{}", profile_id); + // An explicit click can reopen its diagnostic, but must not replace another notice. + if self + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.key.code != code) + { + return true; + } + self.visible_endpoint_notice = Some(ClientVisibleEndpointNotice { + key: ClientEndpointNoticeKey { + boot_id: "machine".into(), + kind: ClientEndpointNoticeKind::Unavailable, + code, + }, + title: format!("{}: {command}", self.endpoint_label(&id)), + body: error.clone(), + deadline: std::time::Instant::now() + std::time::Duration::from_secs(15), + }); + outcome.repaint = true; + true + } +} diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 80387019..82cb2ac4 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -230,6 +230,7 @@ pub(super) fn render_mode_bar( } pub(super) struct ShellRenderState<'a> { + pub(super) machine_diagnostics: &'a super::machine_diagnostics::MachineDiagnostics, pub(super) endpoints: &'a [ClientShellEndpoint], pub(super) active_endpoint_id: &'a ClientEndpointId, pub(super) collapsed_endpoints: &'a HashSet<ClientEndpointId>, diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 2eaf18bf..0d6937e0 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -844,6 +844,7 @@ pub(super) struct ClientCopyModeState { } pub(crate) struct ClientShellState { + pub(super) machine_diagnostics: super::machine_diagnostics::MachineDiagnostics, pub(super) config: ClientShellConfig, pub(super) snapshot: Option<Box<ClientShellSnapshot>>, pub(super) active_snapshot_generation: Option<u64>, @@ -1007,6 +1008,7 @@ impl ClientShellState { .extend(saved.collapsed_groups); } Self { + machine_diagnostics: Default::default(), config, snapshot: None, active_snapshot_generation: None, diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 4434e119..f327f8c1 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -73,6 +73,45 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) { (state, endpoint_id) } +#[test] +fn machine_diagnostic_badge_reopens_notice_without_collapsing_machine() { + let (mut state, id) = state_with_remote(); + state.set_endpoint_status(&id, ClientEndpointStatus::Attention); + state.set_machine_diagnostic(&id, "Permission denied (keyboard-interactive)".into()); + for _ in 0..2 { + state.compose(120, 40).unwrap(); + let hit = state + .hits + .machines + .iter() + .find(|hit| hit.endpoint_id == id) + .unwrap(); + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.status_badge.x, + row: hit.status_badge.y, + modifiers: KeyModifiers::NONE, + }; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(outcome.repaint); + assert!(!state.collapsed_endpoints.contains(&id)); + let notice = state.visible_endpoint_notice.take().unwrap(); + assert!(notice.body.contains("Permission denied")); + assert!(notice + .title + .contains("herdr machine reconnect 0123456789abcdef0123456789abcdef")); + } + state.set_endpoint_status(&id, ClientEndpointStatus::Online); + state.compose(120, 40).unwrap(); + assert!(!state.machine_diagnostics.required_for( + state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == id) + .unwrap() + )); +} + fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) { let (mut state, remote) = state_with_remote(); for endpoint_id in [ClientEndpointId::Local, remote.clone()] { diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs index 2e8d1c72..3843654f 100644 --- a/src/client/shell_runtime.rs +++ b/src/client/shell_runtime.rs @@ -597,6 +597,7 @@ pub(super) fn handle_endpoint_attention( shell.cancel_endpoint_request(&request_id); } shell.set_endpoint_status(endpoint_id, endpoint::ClientEndpointStatus::Attention); + shell.set_machine_diagnostic(endpoint_id, message.clone()); endpoint_was_active.then(|| format!("{}: {message}", shell.endpoint_label(endpoint_id))) }); if let Some(message) = unavailable { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 864516da..88f433bf 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -81,7 +81,9 @@ impl ChildExitReason { } #[cfg(unix)] -pub(crate) use unix_common::{classify_child_exit, poll_fd_readable, read_fd}; +pub(crate) use unix_common::{ + classify_child_exit, poll_fd_readable, read_fd, shared_ssh_control_path, +}; #[cfg(not(any(unix, windows)))] pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason { @@ -712,3 +714,14 @@ mod tests { ); } } + +#[cfg(not(unix))] +pub(crate) fn shared_ssh_control_path( + _namespace: &std::path::Path, + _target: &str, +) -> std::io::Result<std::path::PathBuf> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "interactive SSH recovery requires Unix OpenSSH multiplexing", + )) +} diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index 6063edd9..8b76212c 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -498,3 +498,139 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } } + +/// Shared OpenSSH sockets outlive individual helpers. Never adopt a directory +/// belonging to another uid, a symlink, or a directory accessible by others. +pub(crate) fn shared_ssh_control_path(namespace: &Path, target: &str) -> std::io::Result<PathBuf> { + use sha2::{Digest, Sha256}; + use std::os::unix::{ + ffi::OsStrExt, + fs::{DirBuilderExt, MetadataExt}, + }; + + // Validate the resolved system temp directory, but retain the short /tmp + // spelling for sockets. On macOS /tmp resolves to /private/tmp; those extra + // bytes would consume the space OpenSSH needs for its staging suffix. + let base = Path::new("/tmp"); + let resolved_base = std::fs::canonicalize(base)?; + let metadata = std::fs::symlink_metadata(&resolved_base)?; + if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o1000 == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "unsafe SSH control directory parent", + )); + } + let dir = base.join(format!("hssh-{}", unsafe { libc::geteuid() })); + match std::fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + validate_shared_ssh_dir(&dir)?; + let namespace = if namespace.is_absolute() { + namespace.to_owned() + } else { + std::env::current_dir()?.join(namespace) + }; + let mut hash = Sha256::new(); + hash.update(namespace.as_os_str().as_bytes()); + hash.update([0]); + hash.update(target.as_bytes()); + // %C additionally scopes the socket to OpenSSH's resolved destination, + // port and jump host, rather than merely the spelling of an alias. + // Keep 96 bits of namespace/target hash plus OpenSSH's 160-bit %C. + let hash = format!("{:x}", hash.finalize()); + let path = dir.join(format!("{}-%C", &hash[..24])); + // OpenSSH first binds ControlPath + '.' + 16 random characters, then + // renames it. Reserve those 17 bytes, not just the final socket's length. + let expanded = path.to_string_lossy().replace("%C", &"0".repeat(40)); + let staging = PathBuf::from(format!("{expanded}.{}", "0".repeat(16))); + if !fits_unix_socket_path(&staging) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "SSH control socket staging path exceeds the Unix socket length limit", + )); + } + Ok(path) +} + +fn validate_shared_ssh_dir(dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::symlink_metadata(dir)?; + if !metadata.is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o7777 != 0o700 + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "SSH control directory must be owned by the current user, mode 0700, and not a symlink", + )); + } + Ok(()) +} + +#[cfg(test)] +mod shared_ssh_tests { + use super::*; + + #[test] + fn shared_ssh_control_path_is_stable_scoped_and_bounded() { + let path = shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap(); + assert_eq!( + path, + shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap() + ); + assert_ne!( + path, + shared_ssh_control_path(Path::new("/config/two"), "user@host").unwrap() + ); + assert_ne!( + path, + shared_ssh_control_path(Path::new("/config/one"), "other@host").unwrap() + ); + let expanded = path.to_string_lossy().replace("%C", &"f".repeat(40)); + assert!(fits_unix_socket_path(&PathBuf::from(&expanded))); + // OpenSSH binds this temporary socket before renaming it to ControlPath. + assert!(fits_unix_socket_path(&PathBuf::from(format!( + "{expanded}.QuuYe7ZFE2HYeAE4" + )))); + validate_shared_ssh_dir(path.parent().unwrap()).unwrap(); + } + + #[test] + fn shared_ssh_staging_path_fits_with_maximum_uid_width() { + let path = shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap(); + let directory = path.parent().unwrap(); + let name = directory.file_name().unwrap().to_string_lossy(); + let prefix = name.trim_end_matches(|ch: char| ch.is_ascii_digit()); + let maximum_uid_directory = directory + .parent() + .unwrap() + .join(format!("{prefix}{}", u32::MAX)); + let expanded = maximum_uid_directory + .join(path.file_name().unwrap()) + .to_string_lossy() + .replace("%C", &"f".repeat(40)); + assert!(fits_unix_socket_path(&PathBuf::from(format!( + "{expanded}.QuuYe7ZFE2HYeAE4" + )))); + } + + #[test] + fn shared_ssh_directory_rejects_symlinks_and_public_modes() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let dir = create_remote_ssh_config_dir("ctl").unwrap(); + let link = dir.join("link"); + symlink(&dir, &link).unwrap(); + assert_eq!( + validate_shared_ssh_dir(&link).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + validate_shared_ssh_dir(&dir).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 8e0950b9..a4a00dea 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -86,6 +86,39 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { run_client_process(&local_socket, &reattach_command, remote.keybindings) } +pub(crate) fn check_saved_ssh(target: &str, session: &str) -> io::Result<()> { + super::validate_remote_target(target) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + crate::session::validate_name(session) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let mut ssh = RemoteSsh::new_noninteractive(target.to_owned()); + ssh.session_name = session.to_owned(); + let remote = find_installed_remote_herdr(&ssh)?; + match remote_server_status(&ssh, &remote, false)? { + RemoteServerStatus::Running { + endpoint_protocol_generation, + surface_interest, + health_check, + detached_server_daemon, + .. + } if remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + true, + surface_interest, + health_check, + ) + .is_none() => + { + Ok(()) + } + _ => Err(io::Error::other(format!( + "remote Herdr server is stopped or incompatible; run `{}`", + super::saved_ssh_bootstrap_command(target, session), + ))), + } +} + pub(crate) fn prepare_saved_ssh( target: &str, session_name: &str, @@ -554,17 +587,92 @@ pub(super) struct PreparedRemoteHerdr { pub(super) struct ManagedSshOptions { config_path: PathBuf, control_path: Option<PathBuf>, + // Bridge workers may launch SSH after the helper that created this config + // has gone away. The last options owner removes only the temporary config. + _directory: Arc<ManagedSshConfigDirectory>, } struct ManagedSshConfig { options: ManagedSshOptions, } -impl Drop for ManagedSshConfig { +struct ManagedSshConfigDirectory(PathBuf); + +impl Drop for ManagedSshConfigDirectory { fn drop(&mut self) { - if let Some(dir) = self.options.config_path.parent() { - let _ = fs::remove_dir_all(dir); - } + let _ = fs::remove_dir_all(&self.0); + } +} + +/// Classify only SSH authentication diagnostics, not transport failures or +/// unknown/changed host keys. This does not imply permission to prompt. +pub(crate) fn ssh_error_requires_authentication(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + if message.contains("host key verification failed") + || message.contains("remote host identification has changed") + { + return false; + } + (message.contains("permission denied") + && ["(publickey", "(keyboard-interactive", "(password"] + .iter() + .any(|method| message.contains(method))) + || (message.contains("signing failed") + && (message.contains("sign_and_send_pubkey") || message.contains("agent"))) +} + +/// Keep this owner alive until the child has exited: OpenSSH reads its temporary +/// config after spawn. Dropping it never stops the shared authenticated master. +pub(crate) struct SshAuthenticationCommand { + pub(crate) command: Command, + _config: ManagedSshConfig, +} + +pub(crate) fn ssh_authentication_command(target: &str) -> io::Result<SshAuthenticationCommand> { + if target.is_empty() || target.starts_with('-') || target.chars().any(char::is_control) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid SSH target", + )); + } + if !crate::platform::remote_ssh_config_paths().multiplexing { + return Err(io::Error::new(io::ErrorKind::Unsupported, "interactive SSH recovery requires Unix OpenSSH multiplexing; authenticate outside Herdr on this platform")); + } + if !crate::config::Config::load() + .config + .remote + .manage_ssh_config + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "interactive SSH recovery requires remote.manage_ssh_config=true", + )); + } + let config = write_managed_ssh_config(target)?; + Ok(authentication_command_with_config(target, config)) +} + +fn authentication_command_with_config( + target: &str, + config: ManagedSshConfig, +) -> SshAuthenticationCommand { + let mut command = Command::new("ssh"); + apply_managed_ssh_options(&mut command, Some(&config.options)); + command + .env("SSH_ASKPASS_REQUIRE", "never") + .env_remove("SSH_ASKPASS") + .arg("-o") + .arg("BatchMode=no") + .arg("-o") + .arg("StrictHostKeyChecking=yes") + .arg("-o") + .arg("NumberOfPasswordPrompts=3") + .arg("-T") + .arg(target) + .arg("exit"); + SshAuthenticationCommand { + command, + _config: config, } } @@ -578,7 +686,7 @@ pub(super) struct RemoteSsh { impl RemoteSsh { fn new(target: String, manage_ssh_config: bool, session_name: String) -> Self { let managed_config = if manage_ssh_config { - write_managed_ssh_config() + write_managed_ssh_config(&target) .inspect_err(|err| { tracing::debug!(%err, "could not write managed ssh config; using plain ssh"); }) @@ -596,12 +704,14 @@ impl RemoteSsh { } pub(super) fn new_noninteractive(target: String) -> Self { - Self { - target, - session_name: crate::session::DEFAULT_SESSION_NAME.into(), - managed_config: None, - noninteractive: true, - } + let manage = crate::platform::remote_ssh_config_paths().multiplexing + && crate::config::Config::load() + .config + .remote + .manage_ssh_config; + let mut ssh = Self::new(target, manage, crate::session::DEFAULT_SESSION_NAME.into()); + ssh.noninteractive = true; + ssh } fn target(&self) -> &str { @@ -1001,31 +1111,6 @@ fn decode_windows_remote_path(encoded: &str) -> io::Result<String> { Ok(path) } -impl Drop for RemoteSsh { - fn drop(&mut self) { - let Some(_options) = self - .managed_config - .as_ref() - .map(|config| &config.options) - .filter(|options| options.control_path.is_some()) - else { - return; - }; - - let _ = self - .base_command() - .arg("-O") - .arg("exit") - .arg("-o") - .arg("BatchMode=yes") - .arg(&self.target) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } -} - fn apply_noninteractive_ssh_options(command: &mut Command) { command .arg("-o") @@ -1053,13 +1138,16 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO command.arg("-F").arg(&options.config_path); if let Some(control_path) = &options.control_path { + // User ControlPaths may be shared across isolated Herdr configs (or + // explicitly disabled). Managed auth must use our scoped transport; + // never stop or unlink a master belonging to the user's SSH setup. command .arg("-S") .arg(control_path) .arg("-o") .arg("ControlMaster=auto") .arg("-o") - .arg("ControlPersist=yes"); + .arg("ControlPersist=600"); } } @@ -1080,7 +1168,7 @@ fn apply_managed_scp_options(command: &mut Command, options: Option<&ManagedSshO .arg("-o") .arg("ControlMaster=auto") .arg("-o") - .arg("ControlPersist=yes"); + .arg("ControlPersist=600"); } } @@ -1947,7 +2035,7 @@ fn probe_remote_endpoint( remote_herdr.clone(), path.clone(), ssh.session_name.clone(), - None, + ssh.options(), true, )?; let mut stream = crate::ipc::connect_local_stream(&path)?; @@ -2723,14 +2811,19 @@ fn ssh_user_config_include(path: Option<&Path>) -> Option<String> { /// Builds a temporary ssh config that includes the user's settings first, so /// OpenSSH's first-value-wins behavior preserves explicit user keepalives. -fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> { +fn write_managed_ssh_config(target: &str) -> io::Result<ManagedSshConfig> { let paths = crate::platform::remote_ssh_config_paths(); + let control_path = if paths.multiplexing { + Some(crate::platform::shared_ssh_control_path( + &crate::config::config_path(), + target, + )?) + } else { + None + }; + let dir = crate::platform::create_remote_ssh_config_dir(SSH_CONTROL_SOCKET_NAME)?; let path = dir.join("config"); - let control_path = paths - .multiplexing - .then(|| dir.join(SSH_CONTROL_SOCKET_NAME)); - let mut contents = String::new(); if let Some(include) = ssh_user_config_include(paths.user_config.as_deref()) { contents.push_str(&format!("Include {include}\n")); @@ -2757,6 +2850,7 @@ fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> { options: ManagedSshOptions { config_path: path, control_path, + _directory: Arc::new(ManagedSshConfigDirectory(dir)), }, }) } @@ -3531,7 +3625,7 @@ mod tests { fn managed_ssh_config_includes_user_config_then_fallback() { use std::os::unix::fs::PermissionsExt; - let managed_config = write_managed_ssh_config().expect("write managed config"); + let managed_config = write_managed_ssh_config("example").expect("write managed config"); let path = managed_config.options.config_path.clone(); let control_path = managed_config .options @@ -3591,6 +3685,106 @@ mod tests { drop(managed_config); } + #[cfg(unix)] + #[test] + fn shared_ssh_transport_survives_helper_config_drop() { + let first = write_managed_ssh_config("example").unwrap(); + let second = write_managed_ssh_config("example").unwrap(); + let socket = first.options.control_path.clone().unwrap(); + assert_eq!(Some(&socket), second.options.control_path.as_ref()); + assert_ne!(socket.parent(), first.options.config_path.parent()); + let config_path = first.options.config_path.clone(); + drop(first); + assert!(!config_path.exists()); + assert!(socket.parent().unwrap().is_dir()); + } + + #[test] + fn ssh_authentication_diagnostics_are_narrow() { + for message in [ + "user@host: Permission denied (publickey).", + "Permission denied (keyboard-interactive,password).", + "Permission denied (password).", + "sign_and_send_pubkey: signing failed for ED25519 from agent: agent refused operation", + ] { + assert!(ssh_error_requires_authentication(message), "{message}"); + } + for message in [ + "Host key verification failed.", + "REMOTE HOST IDENTIFICATION HAS CHANGED!", + "Permission denied opening /tmp/file", + "Connection refused", + "agent disconnected", + "Permission denied (publickey). Host key verification failed.", + ] { + assert!(!ssh_error_requires_authentication(message), "{message}"); + } + } + + #[test] + fn bridge_options_keep_temporary_config_alive_after_helper_drop() { + let config = write_managed_ssh_config("example").unwrap(); + let path = config.options.config_path.clone(); + let worker_options = config.options.clone(); + drop(config); + assert!(path.is_file()); + drop(worker_options); + assert!(!path.exists()); + } + + #[cfg(unix)] + #[test] + fn authentication_command_uses_shared_transport_without_askpass_or_host_key_relaxation() { + let config = write_managed_ssh_config("example").unwrap(); + let setup = RemoteSsh::new("example".into(), true, "other-session".into()); + assert_eq!( + config.options.control_path, + setup.options().unwrap().control_path + ); + let authentication = authentication_command_with_config("example", config); + let command = &authentication.command; + assert_eq!(command.get_program(), "ssh"); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::<Vec<_>>(); + for required in [ + "ControlMaster=auto", + "ControlPersist=600", + "BatchMode=no", + "StrictHostKeyChecking=yes", + ] { + assert!(args.iter().any(|arg| arg == required), "missing {required}"); + } + assert_eq!(&args[args.len() - 3..], &["-T", "example", "exit"]); + let env = command.get_envs().collect::<Vec<_>>(); + assert!(env.iter().any( + |(key, value)| *key == std::ffi::OsStr::new("SSH_ASKPASS_REQUIRE") + && *value == Some(std::ffi::OsStr::new("never")) + )); + assert!(env + .iter() + .any(|(key, value)| *key == std::ffi::OsStr::new("SSH_ASKPASS") && value.is_none())); + } + + #[test] + fn authentication_command_rejects_option_injection() { + assert_eq!( + ssh_authentication_command("-oProxyCommand=bad") + .err() + .unwrap() + .kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn unmanaged_ssh_setup_preserves_plain_transport() { + let ssh = RemoteSsh::new("example".into(), false, "main".into()); + assert!(ssh.options().is_none()); + assert!(!ssh.command().get_args().any(|arg| arg == "-F")); + } + #[test] fn ssh_config_quote_wraps_path_with_spaces() { assert_eq!( @@ -3602,14 +3796,9 @@ mod tests { #[cfg(unix)] #[test] fn remote_ssh_command_uses_managed_config_when_present() { - let mut managed_config = write_managed_ssh_config().expect("write managed config"); - managed_config.options.control_path = Some(PathBuf::from("/tmp/herdr test/control")); + let managed_config = write_managed_ssh_config("example").expect("write managed config"); let config_path = managed_config.options.config_path.clone(); - let control_path = managed_config - .options - .control_path - .clone() - .expect("Unix managed config has a control path"); + let control_path = managed_config.options.control_path.clone().unwrap(); let ssh = RemoteSsh { target: "example".to_string(), session_name: crate::session::DEFAULT_SESSION_NAME.into(), @@ -3634,7 +3823,7 @@ mod tests { "-o".to_string(), "ControlMaster=auto".to_string(), "-o".to_string(), - "ControlPersist=yes".to_string(), + "ControlPersist=600".to_string(), "-T".to_string(), "example".to_string(), ] @@ -3656,7 +3845,7 @@ mod tests { "-o".to_string(), "ControlMaster=auto".to_string(), "-o".to_string(), - "ControlPersist=yes".to_string(), + "ControlPersist=600".to_string(), ] ); } @@ -3664,7 +3853,7 @@ mod tests { #[cfg(windows)] #[test] fn windows_managed_ssh_config_uses_keepalives_without_control_socket() { - let managed_config = write_managed_ssh_config().expect("write managed config"); + let managed_config = write_managed_ssh_config("example").expect("write managed config"); let config_path = managed_config.options.config_path.clone(); assert!(managed_config.options.control_path.is_none()); let contents = std::fs::read_to_string(&config_path).expect("read managed config"); @@ -3741,6 +3930,44 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn endpoint_probe_preserves_setup_ssh_options() { + let managed_config = write_managed_ssh_config("example").expect("write managed config"); + let marker = managed_config + .options + .config_path + .with_file_name("probe-ran"); + fs::write( + &managed_config.options.config_path, + format!( + "Host *\n ProxyCommand /bin/sh -c {}\n", + shell_quote(&format!( + ": > {}; exit 1", + shell_quote(&marker.to_string_lossy()) + )) + ), + ) + .expect("write isolated probe config"); + let ssh = RemoteSsh { + target: "herdr-probe.invalid".into(), + session_name: "probe-options".into(), + managed_config: Some(managed_config), + noninteractive: false, + }; + let remote = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + + let error = probe_remote_endpoint(&ssh, &remote).expect_err("proxy refuses connection"); + + assert!( + marker.exists(), + "endpoint probe discarded the authenticated setup's SSH options: {error}" + ); + } + #[test] fn noninteractive_ssh_stderr_capture_is_bounded() { let stderr = vec![b'x'; NONINTERACTIVE_SSH_STDERR_LIMIT + 4096]; @@ -3768,8 +3995,7 @@ mod tests { ] { assert!(args.iter().any(|arg| arg == required), "missing {required}"); } - assert!(!args.iter().any(|arg| arg == "-F")); - assert!(ssh.options().is_none()); + assert_eq!(args.iter().any(|arg| arg == "-F"), ssh.options().is_some()); } #[test] From dd33ddf28d693b2c64ed91f75656c61416ee2538 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Tue, 22 Sep 2026 18:43:12 +0200 Subject: [PATCH 47/99] fix: move worktree lookups async (#4492) --- src/app/actions.rs | 1 + src/app/api.rs | 22 +- src/app/api/worktrees.rs | 728 +++++++++++++---------- src/app/api/worktrees/deferred.rs | 6 + src/app/api/worktrees/reads.rs | 339 +++++++++++ src/app/mod.rs | 2 + src/events.rs | 23 + src/server/headless.rs | 17 +- src/server/headless/client_views.rs | 27 +- src/server/headless/endpoint_requests.rs | 14 +- src/server/headless/notifications.rs | 57 +- src/server/headless/pane_graphics.rs | 2 +- src/server/headless/tests/mod.rs | 249 +++++++- src/worktree.rs | 43 ++ 14 files changed, 1156 insertions(+), 374 deletions(-) create mode 100644 src/app/api/worktrees/reads.rs diff --git a/src/app/actions.rs b/src/app/actions.rs index 953bcd94..e1bedd26 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1604,6 +1604,7 @@ impl AppState { } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), + AppEvent::WorktreeReadFinished(_) => Vec::new(), AppEvent::TabBarCommandFinished { .. } => Vec::new(), AppEvent::PluginCommandFinished { .. } => Vec::new(), } diff --git a/src/app/api.rs b/src/app/api.rs index 1b5bad71..c56a07a1 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -39,6 +39,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 +165,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(); @@ -1035,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( @@ -1044,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( diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index f578f101..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<crate::worktree::ExistingWorktree>, ) -> 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<String>, - cwd: Option<String>, - trust_repository: bool, - ) -> Result<WorktreeSource, ApiFailure> { - 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<WorktreeSource, ApiFailure> { 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<WorktreeSource, ApiFailure> { - 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<usize> { - 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<usize> { self.state.workspaces.iter().position(|ws| { ws.worktree_space() @@ -495,52 +354,6 @@ impl App { } } - fn find_worktree_entry( - &self, - source: &WorktreeSource, - path: Option<String>, - branch: Option<String>, - trust_repository: bool, - ) -> Result<crate::worktree::ExistingWorktree, ApiFailure> { - 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::<Vec<_>>(); - 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(), @@ -682,49 +495,47 @@ impl App { } } -fn worktree_source_from_space( - space: crate::workspace::GitSpaceMetadata, - workspace_idx: Option<usize>, - 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<crate::worktree::ExistingWorktree>, + path: Option<String>, + branch: Option<String>, +) -> Result<crate::worktree::ExistingWorktree, ApiFailure> { + 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::<Vec<_>>(); + 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 { @@ -1532,13 +1352,16 @@ mod tests { app.mark_worktree_membership(&source, 1, checkout.clone(), true, false); let child_membership = app.state.workspaces[1].worktree_space().cloned(); - let response = app.handle_api_request(Request { - id: "list".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(parent_id.clone()), - ..WorktreeListParams::default() - }), - }); + 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"); @@ -1549,15 +1372,18 @@ mod tests { .unwrap(); assert_eq!(entry.open_workspace_id.as_deref(), Some(child_id.as_str())); - let response = app.handle_api_request(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 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, @@ -1616,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 { @@ -1664,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"); @@ -1671,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}"); @@ -1739,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 { @@ -1803,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"); @@ -1849,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 { @@ -2075,6 +2146,7 @@ mod tests { }), }, respond_to, + false, )); assert!(response_rx.try_recv().is_err()); @@ -2149,6 +2221,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2160,6 +2233,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2220,6 +2294,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2231,6 +2306,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2267,6 +2343,7 @@ mod tests { }), }, respond_to, + false, )); let response = response_rx @@ -2322,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<String>, + 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<String>, + membership: Option<WorktreeSpaceMembership>, + git_space: Option<GitSpaceMetadata>, + cwd: Option<PathBuf>, +} + +impl SourceInput { + fn resolve( + mut self, + allow_linked: bool, + trust_repository: bool, + ) -> Result<(Option<String>, 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<String>, + cwd: &Option<String>, + ) -> Result<SourceInput, ApiFailure> { + 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<String>, + 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/mod.rs b/src/app/mod.rs index 7ff3040b..03e89026 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -124,6 +124,7 @@ pub struct App { pub(crate) git_identity_refresh_requested: bool, pub(crate) git_status_cache: HashMap<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>, pub(crate) pending_api_worktree_creates: HashMap<std::path::PathBuf, u64>, + pub(crate) worktree_read_slots: std::sync::Arc<tokio::sync::Semaphore>, pub(crate) pending_api_worktree_removes: HashMap<String, u64>, pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>, pub(crate) pending_worktree_remove_runtime_exits: HashMap<crate::layout::PaneId, usize>, @@ -588,6 +589,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(), diff --git a/src/events.rs b/src/events.rs index 95638991..a614ee0c 100644 --- a/src/events.rs +++ b/src/events.rs @@ -52,6 +52,27 @@ pub struct WorktreeRemoveResult { pub result: Result<(), String>, } +#[derive(Debug)] +pub struct WorktreeReadResult { + // Keep the slot until completion is consumed, including time queued on the app loop. + pub(crate) _permit: tokio::sync::OwnedSemaphorePermit, + pub(crate) client_local: bool, + pub(crate) request: crate::api::schema::Request, + pub(crate) source_workspace_id: Option<String>, + pub(crate) source_cwd: Option<std::path::PathBuf>, + pub(crate) result: Result<WorktreeReadData, (String, String)>, + pub(crate) respond_to: std::sync::mpsc::Sender<String>, +} + +#[derive(Debug)] +pub(crate) struct WorktreeReadData { + pub source_checkout_path: std::path::PathBuf, + pub source_repo_root: std::path::PathBuf, + pub repo_key: String, + pub repo_name: String, + pub entries: Vec<crate::worktree::ExistingWorktree>, +} + /// An event from a background task to the main loop. #[derive(Debug)] pub enum AppEvent { @@ -173,4 +194,6 @@ pub enum AppEvent { WorktreeAddFinished(Box<WorktreeAddResult>), /// Background `git worktree remove` completed. WorktreeRemoveFinished(Box<WorktreeRemoveResult>), + /// Background worktree discovery completed for an API list/open request. + WorktreeReadFinished(Box<WorktreeReadResult>), } diff --git a/src/server/headless.rs b/src/server/headless.rs index 0aed2a37..bba59498 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2970,6 +2970,7 @@ impl HeadlessServer { &mut self, msg: api::ApiRequestMessage, skip_default_workspace_for_request: bool, + client_local: bool, ) -> bool { if self.shutting_down { // During shutdown, respond with server_unavailable. @@ -3124,12 +3125,18 @@ impl HeadlessServer { } if matches!( &msg.request.method, - api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_) + api::schema::Method::WorktreeCreate(_) + | api::schema::Method::WorktreeRemove(_) + | api::schema::Method::WorktreeList(_) + | api::schema::Method::WorktreeOpen(_) ) { - let deferred_changed = self - .app - .handle_deferred_worktree_api_request(msg.request, msg.respond_to); - return changed | deferred_changed; + let read_only = matches!(&msg.request.method, api::schema::Method::WorktreeList(_)); + let deferred_changed = self.app.handle_deferred_worktree_api_request( + msg.request, + msg.respond_to, + client_local, + ); + return changed | (deferred_changed && !read_only); } if self.foreground_client_id.is_some_and(|client_id| { self.clients diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index c2e3c2d5..31b39be2 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -313,7 +313,10 @@ impl HeadlessServer { pub(super) fn deferred_endpoint_navigation_tab_id(response: &[u8]) -> Option<String> { let response = serde_json::from_slice::<serde_json::Value>(response).ok()?; - if response.pointer("/result/type")?.as_str()? != "worktree_created" { + if !matches!( + response.pointer("/result/type")?.as_str()?, + "worktree_created" | "worktree_opened" + ) { return None; } response @@ -851,24 +854,17 @@ impl HeadlessServer { api::schema::Method::TabCreate(params) => params.focus, _ => false, }; - let inspect_worktree_open = matches!( - &msg.request.method, - api::schema::Method::WorktreeOpen(params) if params.focus - ); let inspect_pane_move = matches!( &msg.request.method, api::schema::Method::PaneMove(params) if params.focus ); - let response_proxy = (agent_focus_target.is_some() - || inspect_worktree_open - || inspect_pane_move) - .then(|| { - let (proxy_tx, proxy_rx) = std::sync::mpsc::channel(); - let original = std::mem::replace(&mut msg.respond_to, proxy_tx); - (original, proxy_rx) - }); + let response_proxy = (agent_focus_target.is_some() || inspect_pane_move).then(|| { + let (proxy_tx, proxy_rx) = std::sync::mpsc::channel(); + let original = std::mem::replace(&mut msg.respond_to, proxy_tx); + (original, proxy_rx) + }); let reconcile = Self::shell_locations_may_need_reconcile(&msg.request.method); - let changed = self.handle_api_request_with_shutdown_check_inner(msg, false); + let changed = self.handle_api_request_with_shutdown_check_inner(msg, false, false); let proxied_result = forward_proxied_api_response(response_proxy); let proxied_request_succeeded = proxied_result.is_some(); // Same-tab and zoomed moves succeed without moving or requesting focus. @@ -895,7 +891,6 @@ impl HeadlessServer { .is_some_and(|target| self.default_shell_target() == Some(target)); let public_focus_succeeded = explicit_focus_succeeded || (create_focus_requested && target_changed) - || (inspect_worktree_open && proxied_request_succeeded) || pane_move_focus_succeeded; if public_focus_succeeded { self.focus_all_shell_clients_on_default_target(); @@ -927,7 +922,7 @@ impl HeadlessServer { self.set_default_shell_target_from_client(client_id); let popup_before = self.app.state.popup_pane.is_some(); let popup_owner = self.shell_tab_id_for_client(client_id); - let changed = self.handle_api_request_with_shutdown_check_inner(msg, false); + let changed = self.handle_api_request_with_shutdown_check_inner(msg, false, true); self.focus_shell_client_on_default_target(client_id); if !popup_before && self.app.state.popup_pane.is_some() { self.popup_owner_tab_id = popup_owner; diff --git a/src/server/headless/endpoint_requests.rs b/src/server/headless/endpoint_requests.rs index 4f02b676..afdcc80a 100644 --- a/src/server/headless/endpoint_requests.rs +++ b/src/server/headless/endpoint_requests.rs @@ -105,12 +105,16 @@ impl HeadlessServer { client.shell_endpoint_command_surface_revision = Some(client.shell_projection_revision); let deferred_worktree = matches!( &request.method, - api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_) - ); - let deferred_navigation = matches!( - &request.method, - api::schema::Method::WorktreeCreate(params) if params.focus + api::schema::Method::WorktreeCreate(_) + | api::schema::Method::WorktreeRemove(_) + | api::schema::Method::WorktreeList(_) + | api::schema::Method::WorktreeOpen(_) ); + let deferred_navigation = match &request.method { + api::schema::Method::WorktreeCreate(params) => params.focus, + api::schema::Method::WorktreeOpen(params) => params.focus, + _ => false, + }; client.shell_deferred_navigation_request_id = deferred_worktree.then(|| api_request_id.clone()); client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new); diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index a575cfe1..e6d217e2 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -302,19 +302,25 @@ impl HeadlessServer { if self.host_shutdown_requested.load(Ordering::Acquire) { return false; } - let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev { - result + let focus_response = match &mut ev { + AppEvent::WorktreeAddFinished(result) => result .api_request .as_mut() .filter(|request| request.focus) - .map(|request| { - let (proxy_tx, proxy_rx) = std::sync::mpsc::channel(); - let original = std::mem::replace(&mut request.respond_to, proxy_tx); - (original, proxy_rx) - }) - } else { - None + .map(|request| &mut request.respond_to), + AppEvent::WorktreeReadFinished(result) + if matches!(&result.request.method, + api::schema::Method::WorktreeOpen(params) if params.focus) => + { + Some(&mut result.respond_to) + } + _ => None, }; + let mut focused_worktree_response = focus_response.map(|respond_to| { + let (proxy_tx, proxy_rx) = std::sync::mpsc::channel(); + let original = std::mem::replace(respond_to, proxy_tx); + (original, proxy_rx) + }); match &ev { AppEvent::TerminalBell { pane_id, count } => { if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { @@ -568,16 +574,29 @@ impl HeadlessServer { true } - AppEvent::WorktreeAddFinished(result) => { - let deferred_request_id = result - .api_request - .as_ref() - .map(|request| request.id.as_str()); - let shell_navigation_pending = deferred_request_id.is_some_and(|request_id| { - self.clients.values().any(|client| { - client.shell_deferred_navigation_request_id.as_deref() == Some(request_id) - }) - }); + AppEvent::WorktreeReadFinished(result) + if matches!(&result.request.method, api::schema::Method::WorktreeList(_)) => + { + self.app.handle_internal_event_with_render_impact(ev) + } + AppEvent::WorktreeAddFinished(_) | AppEvent::WorktreeReadFinished(_) => { + let deferred_request_id = match &ev { + AppEvent::WorktreeAddFinished(result) => result + .api_request + .as_ref() + .map(|request| request.id.as_str()), + AppEvent::WorktreeReadFinished(result) => Some(result.request.id.as_str()), + _ => None, + }; + let client_local = + matches!(&ev, AppEvent::WorktreeReadFinished(result) if result.client_local); + let shell_navigation_pending = client_local + || deferred_request_id.is_some_and(|request_id| { + self.clients.values().any(|client| { + client.shell_deferred_navigation_request_id.as_deref() + == Some(request_id) + }) + }); let changed = self.app.handle_internal_event_with_render_impact(ev); let api_focus_succeeded = super::client_views::forward_proxied_api_response( focused_worktree_response.take(), diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs index e1410be1..947d2cb8 100644 --- a/src/server/headless/pane_graphics.rs +++ b/src/server/headless/pane_graphics.rs @@ -13,7 +13,7 @@ impl HeadlessServer { msg: api::ApiRequestMessage, ) -> RenderImpact { if self.shutting_down { - let _ = self.handle_api_request_with_shutdown_check_inner(msg, false); + let _ = self.handle_api_request_with_shutdown_check_inner(msg, false, false); return RenderImpact::None; } diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 6accdb09..5f54f4b6 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1804,7 +1804,7 @@ async fn deferred_worktree_response_moves_only_its_source_client() { .get_mut(&51) .unwrap() .shell_deferred_navigation_response = Some(Vec::new()); - let response = serde_json::json!({ + let mut response = serde_json::json!({ "id": "create-worktree", "result": { "type": "worktree_created", @@ -1832,6 +1832,40 @@ async fn deferred_worktree_response_moves_only_its_source_client() { Some(original_tab_id.as_str()) ); + assert!(server.focus_shell_client_on_tab(51, &original_tab_id)); + response["result"]["type"] = serde_json::json!("worktree_opened"); + server + .clients + .get_mut(&51) + .unwrap() + .shell_endpoint_command_surface_revision = Some(source_surface_revision); + server + .clients + .get_mut(&51) + .unwrap() + .shell_endpoint_command_in_flight = true; + server + .clients + .get_mut(&51) + .unwrap() + .shell_deferred_navigation_response = Some(Vec::new()); + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady { + client_id: 51, + boot_id: server.client_shell_boot_id.clone(), + request_id: "open-worktree".into(), + final_chunk: true, + data: serde_json::to_vec(&response).unwrap(), + }) + ); + assert_eq!( + server.shell_tab_id_for_client(51).as_deref(), + Some(created_tab_id.as_str()) + ); + assert_eq!( + server.shell_tab_id_for_client(52).as_deref(), + Some(original_tab_id.as_str()) + ); assert!(server.focus_shell_client_on_tab(51, &original_tab_id)); // A source command begun in the old presentation epoch may finish after source-off and // source-on rollback. Its response remains endpoint-local, but it must not apply deferred @@ -3142,6 +3176,219 @@ async fn client_shell_release_under_popup_renders_when_it_resets_scrollback() { shutdown_test_runtimes(&mut server); } +#[tokio::test] +async fn worktree_discovery_does_not_block_client_typing() { + use api::schema::{Method, WorktreeListParams, WorktreeOpenParams}; + + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("worktree-input"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"", 4); + workspace.insert_test_runtime(pane_id, runtime); + let repo = std::env::temp_dir().join(format!("herdr-blocked-git-{}", workspace.id)); + workspace.identity_cwd = repo.clone(); + workspace.cached_git_space = Some(crate::workspace::GitSpaceMetadata { + key: repo.display().to_string(), + checkout_key: repo.display().to_string(), + repo_name: "blocked-git".into(), + repo_root: repo.clone(), + is_linked_worktree: false, + }); + let workspace_id = workspace.id.clone(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + for (linked, method) in [ + ( + false, + Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id.clone()), + ..Default::default() + }), + ), + ( + true, + Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id.clone()), + ..Default::default() + }), + ), + ( + false, + Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + path: Some(repo.display().to_string()), + focus: true, + ..Default::default() + }), + ), + ] { + server.app.state.workspaces[0] + .cached_git_space + .as_mut() + .unwrap() + .is_linked_worktree = linked; + let (entered, release) = crate::worktree::test_list_gate::block(&repo); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "blocked-read".into(), + method, + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + entered + .recv_timeout(Duration::from_secs(5)) + .expect("Git discovery started"); + assert!(response_rx.try_recv().is_err(), "Git must still be blocked"); + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id.clone(), + events: vec![protocol::ClientPaneInputEvent::TextCommit("x".into())], + }); + assert_eq!( + input_rx + .try_recv() + .expect("typing reaches PTY while Git is blocked"), + Bytes::from_static(b"x") + ); + release.send(()).unwrap(); + let event = tokio::time::timeout(Duration::from_secs(5), server.app.event_rx.recv()) + .await + .unwrap() + .unwrap(); + server.handle_internal_event_with_forwarding(event); + let response: api::schema::ErrorResponse = + serde_json::from_str(&response_rx.recv_timeout(Duration::from_secs(5)).unwrap()) + .unwrap(); + assert_eq!(response.error.code, "worktree_list_failed"); + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn deferred_worktree_open_disconnect_keeps_other_clients_focus() { + let mut server = test_headless_server(); + let mut source = crate::workspace::Workspace::test_new("pending-open-source"); + let repo = std::env::temp_dir().join(format!("herdr-disconnected-open-{}", source.id)); + let checkout = repo.with_extension("checkout"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "--quiet", repo.to_str().unwrap()]); + git(&[ + "-C", + repo.to_str().unwrap(), + "-c", + "user.name=Herdr Test", + "-c", + "user.email=herdr@example.invalid", + "commit", + "--quiet", + "--allow-empty", + "-m", + "initial", + ]); + git(&[ + "-C", + repo.to_str().unwrap(), + "worktree", + "add", + "--quiet", + "-b", + "pending-open", + checkout.to_str().unwrap(), + ]); + source.identity_cwd = repo.clone(); + let source_id = source.id.clone(); + let mut target = crate::workspace::Workspace::test_new("pending-open-target"); + target.identity_cwd = checkout.clone(); + server.app.state.workspaces = vec![source, target]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + let (source_control, _) = connect_matching_test_shell(&mut server, 51); + let (other_control, _) = connect_matching_test_shell(&mut server, 52); + let _ = source_control.recv().unwrap(); + let _ = other_control.recv().unwrap(); + let original_tab = server.shell_tab_id_for_client(52); + + let (entered, release) = crate::worktree::test_list_gate::block(&repo); + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id: 51, + boot_id: server.client_shell_boot_id.clone(), + request: Box::new(api::schema::Request { + id: "open-before-disconnect".into(), + method: api::schema::Method::WorktreeOpen(api::schema::WorktreeOpenParams { + workspace_id: Some(source_id), + branch: Some("pending-open".into()), + focus: true, + ..Default::default() + }), + }), + }); + entered + .recv_timeout(Duration::from_secs(5)) + .expect("Git started"); + server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 51 }); + release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = server.app.event_rx.recv().await.unwrap(); + let completed = matches!(&event, AppEvent::WorktreeReadFinished(_)); + server.handle_internal_event_with_forwarding(event); + if completed { + break; + } + } + }) + .await + .unwrap(); + assert!( + server.app.state.workspaces[1].worktree_space().is_some(), + "open completed successfully" + ); + assert_eq!( + server.shell_tab_id_for_client(52), + original_tab, + "disconnected endpoint must not turn into public navigation" + ); + shutdown_test_runtimes(&mut server); + git(&[ + "-C", + repo.to_str().unwrap(), + "worktree", + "remove", + checkout.to_str().unwrap(), + ]); + let _ = std::fs::remove_dir_all(repo); +} + #[tokio::test] async fn client_shell_text_input_renders_only_when_resetting_scrollback() { let mut server = test_headless_server(); diff --git a/src/worktree.rs b/src/worktree.rs index 72cdb3d2..17f560a4 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -495,6 +495,8 @@ pub(crate) fn list_existing_worktrees( repo_root: &Path, trust_repository: bool, ) -> Result<Vec<ExistingWorktree>, String> { + #[cfg(test)] + test_list_gate::wait(repo_root); let output = repository_git_command(repo_root, trust_repository) .args(["worktree", "list", "--porcelain"]) .output() @@ -513,6 +515,47 @@ pub(crate) fn list_existing_worktrees( }) } +#[cfg(test)] +pub(crate) mod test_list_gate { + use std::path::{Path, PathBuf}; + use std::sync::{mpsc, Mutex}; + + struct Gate { + path: PathBuf, + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, + } + + static GATES: Mutex<Vec<Gate>> = Mutex::new(Vec::new()); + + pub(crate) fn block(path: &Path) -> (mpsc::Receiver<()>, mpsc::Sender<()>) { + let (entered, entry_rx) = mpsc::channel(); + let (release_tx, release) = mpsc::channel(); + GATES.lock().unwrap().push(Gate { + path: super::canonical_or_original(path), + entered, + release, + }); + (entry_rx, release_tx) + } + + pub(super) fn wait(path: &Path) { + let gate = { + let mut gates = GATES.lock().unwrap(); + gates + .iter() + .position(|gate| gate.path == super::canonical_or_original(path)) + .map(|index| gates.remove(index)) + }; + if let Some(gate) = gate { + gate.entered.send(()).unwrap(); + gate.release + .recv_timeout(std::time::Duration::from_secs(10)) + .expect("test must release worktree discovery without blocking the server loop"); + } + } +} + fn worktree_list_contains_path( repo_root: &Path, path: &Path, From 621e6b73c4fe4c13562c0906699003d0ca59f0f1 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Tue, 22 Sep 2026 18:55:34 +0200 Subject: [PATCH 48/99] fix: recognize Windows daemons in non-killing jobs (#4489) refs #4309 --- src/platform/windows.rs | 66 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index cdf4acfc..d96570a6 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1379,7 +1379,8 @@ pub fn current_process_is_detached_server_daemon() -> bool { return false; } - matches!(current_process_is_in_job(), Ok(false)) + // Job membership alone does not tie the daemon lifetime to its launcher. + matches!(current_job_kills_processes_on_close(), Ok(false)) } pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { @@ -3257,6 +3258,67 @@ mod tests { const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID"; const WMI_DAEMON_TEST_CHILD_ENV: &str = "HERDR_TEST_WMI_DAEMON_CHILD"; + #[test] + fn windows_daemon_readiness_checks_job_limits_and_console() { + const CHILD_ENV: &str = "HERDR_TEST_DAEMON_READINESS_CHILD"; + if std::env::var_os(CHILD_ENV).is_some() { + use super::*; + + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + assert!(!job.is_null(), "create test job"); + let job = unsafe { OwnedHandle::from_raw_handle(job) }; + assert_ne!( + unsafe { AssignProcessToJobObject(job.as_raw_handle(), GetCurrentProcess()) }, + 0, + "assign child to test job" + ); + assert!(current_process_is_in_job().unwrap()); + assert!( + current_process_is_detached_server_daemon(), + "a console-free process in a non-killing job must be ready" + ); + + for (flags, ready) in [(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, false), (0, true)] { + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = flags; + assert_ne!( + unsafe { + SetInformationJobObject( + job.as_raw_handle(), + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&limits).cast(), + size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32, + ) + }, + 0, + "set test job limits" + ); + assert_eq!(current_process_is_detached_server_daemon(), ready); + } + assert_ne!(unsafe { AllocConsole() }, 0, "allocate test console"); + assert!(!current_process_is_detached_server_daemon()); + assert_ne!(unsafe { FreeConsole() }, 0, "release test console"); + return; + } + + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "platform::windows::tests::windows_daemon_readiness_checks_job_limits_and_console", + "--nocapture", + ]) + .env(CHILD_ENV, "1"); + super::detach_server_daemon_command(&mut child); + let output = child.output().expect("run daemon readiness child"); + assert!( + output.status.success(), + "daemon readiness child failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + #[test] fn windows_environment_keys_use_unicode_case_insensitive_ordering() { assert_eq!( @@ -3275,7 +3337,7 @@ mod tests { "{}\n{}\n{}", cwd.display(), unsafe { GetConsoleWindow() }.is_null(), - !super::current_job_kills_processes_on_close().expect("inspect WMI daemon job") + super::current_process_is_detached_server_daemon() ), ) .expect("write WMI daemon test capture"); From cd8306d7f35d6329d543944a0257b6bc4b297c8b Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Tue, 22 Sep 2026 22:37:25 +0200 Subject: [PATCH 49/99] fix: detect npm-installed omp on windows (#4502) refs #4313 --- src/detect/mod.rs | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 7fe15d11..3dae571b 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -648,6 +648,15 @@ fn agent_name_from_known_package_path(path: &str) -> Option<String> { ]) { return Some(agent_label(Agent::Pi).to_string()); } + if ends_with(&[ + "node_modules", + "@oh-my-pi", + "pi-coding-agent", + "dist", + "cli.js", + ]) { + return Some(agent_label(Agent::Omp).to_string()); + } if ends_with(&[ "node_modules", "@moonshot-ai", @@ -1433,19 +1442,36 @@ mod tests { #[test] fn identify_agent_in_job_detects_bun_wrapped_omp() { - let job = crate::platform::ForegroundJob { + for (runtime, script) in [ + ("bun", "/home/can/.bun/bin/omp"), + ( + "bun.exe", + r"C:\Users\herdr\AppData\Roaming\npm\node_modules\@oh-my-pi\pi-coding-agent\dist\cli.js", + ), + ] { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, runtime, &[runtime, script])], + }; + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Omp, "omp".to_string())), + "script: {script}" + ); + } + + let other_script = crate::platform::ForegroundJob { process_group_id: 123, processes: vec![foreground_process( 123, - "bun", - &["bun", "/home/can/.bun/bin/omp"], + "bun.exe", + &[ + "bun.exe", + r"C:\Users\herdr\AppData\Roaming\npm\node_modules\@oh-my-pi\pi-coding-agent\dist\setup.js", + ], )], }; - - assert_eq!( - identify_agent_in_job(&job), - Some((Agent::Omp, "omp".to_string())) - ); + assert_eq!(identify_agent_in_job(&other_script), None); } #[test] From 440a8bdd1e3a809ea76f7cee82d73266d9da54d7 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Wed, 23 Sep 2026 02:19:59 +0200 Subject: [PATCH 50/99] fix: keep synchronized pane frames atomic (#4508) refs #2968 --- src/pane.rs | 4 + src/pane/terminal.rs | 59 +++++++ src/server/client_shell.rs | 115 ++++++++++---- src/server/headless/render.rs | 115 ++++++++++---- src/server/headless/retained_surface.rs | 25 ++- src/server/headless/tests/mod.rs | 195 ++++++++++++++++++++++++ src/server/render_scale_benchmark.rs | 9 +- src/server/render_stream.rs | 12 +- src/terminal/runtime.rs | 4 + src/ui/panes.rs | 10 +- 10 files changed, 476 insertions(+), 72 deletions(-) diff --git a/src/pane.rs b/src/pane.rs index 118dd3a4..9bc35eca 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3248,6 +3248,10 @@ impl PaneRuntime { self.terminal.synchronized_output_active() } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.terminal.synchronized_output_state() + } + pub fn visible_text(&self) -> String { self.terminal.visible_text() } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index c9d0b90a..e2005a8f 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -195,6 +195,7 @@ pub(crate) struct GhosttyPaneCore { #[cfg(test)] pub dirty_collection_hook: Option<Box<dyn FnOnce() + Send>>, pub terminal: crate::ghostty::Terminal, + synchronized_output_epoch: u64, #[cfg(windows)] recent_fallback: windows_recent_fallback::Cache, pub render_state: crate::ghostty::RenderState, @@ -482,6 +483,10 @@ impl PaneTerminal { self.ghostty.synchronized_output_active() } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.ghostty.synchronized_output_state() + } + pub fn visible_text(&self) -> String { self.ghostty.visible_text() } @@ -1167,6 +1172,7 @@ impl GhosttyPaneTerminal { #[cfg(test)] dirty_collection_hook: None, terminal, + synchronized_output_epoch: 0, #[cfg(windows)] recent_fallback: windows_recent_fallback::Cache::default(), render_state, @@ -1387,6 +1393,10 @@ impl GhosttyPaneTerminal { core.decscusr_tracker.observe(bytes); let in_progress_default_color_event = core.default_color_event_tracker.in_progress_event(); let default_color_events = core.default_color_event_tracker.drain_pending(); + let synchronized_output_before = core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false); let write_started = crate::render_prof::timer(); self.write_pty_bytes_with_ordered_responses( &mut core, @@ -1420,6 +1430,9 @@ impl GhosttyPaneTerminal { .terminal .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) .unwrap_or(false); + if synchronized_output != synchronized_output_before { + core.synchronized_output_epoch = core.synchronized_output_epoch.wrapping_add(1); + } // Intermediate synchronized-frame positions must not become settled cursors. if CURSOR_POSITION_SETTLE_ENABLED && !synchronized_output { let cursor_started = crate::render_prof::timer(); @@ -1660,6 +1673,10 @@ impl GhosttyPaneTerminal { cell_height_px: u32, ) -> Vec<Bytes> { if let Ok(mut core) = self.core.lock() { + let synchronized_output_before = core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false); #[cfg(windows)] windows_recent_fallback::refresh_if_needed(&mut core); let offset_from_bottom = core @@ -1698,6 +1715,13 @@ impl GhosttyPaneTerminal { let _ = core .terminal .resize(cols, rows, cell_width_px, cell_height_px); + let synchronized_output_after = core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false); + if synchronized_output_after != synchronized_output_before { + core.synchronized_output_epoch = core.synchronized_output_epoch.wrapping_add(1); + } let terminal_responses = self.drain_pending_pty_responses(); let bottom_is_blank = ghostty_detection_text(&mut core) @@ -1971,6 +1995,20 @@ impl GhosttyPaneTerminal { .unwrap_or(false) } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.core + .lock() + .map(|core| { + ( + core.terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false), + core.synchronized_output_epoch, + ) + }) + .unwrap_or((true, 0)) + } + pub fn encode_terminal_key( &self, key: crate::input::TerminalKey, @@ -2308,6 +2346,13 @@ impl GhosttyPaneTerminal { let Ok(mut core) = self.core.lock() else { return; }; + if core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false) + { + return; + } let host_theme = core.host_terminal_theme; let initial_default_foreground = core.initial_default_foreground; let initial_default_background = core.initial_default_background; @@ -2425,6 +2470,13 @@ impl GhosttyPaneTerminal { .lock() .ok() .map(|mut core| { + if core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false) + { + return TerminalDirtyPatchOutcome::Fallback; + } #[cfg(test)] if let Some(hook) = core.dirty_collection_hook.take() { hook(); @@ -5947,14 +5999,21 @@ mod tests { let pane_terminal = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); + assert_eq!(pane_terminal.synchronized_output_state(), (false, 0)); + pane_terminal.process_pty_bytes(pane_id, 0, b"ordinary output", &tx); + assert_eq!(pane_terminal.synchronized_output_state(), (false, 0)); + let begin = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026h", &tx); assert!(!begin.request_render); + assert_eq!(pane_terminal.synchronized_output_state(), (true, 1)); let body = pane_terminal.process_pty_bytes(pane_id, 0, b"hello", &tx); assert!(!body.request_render); + assert_eq!(pane_terminal.synchronized_output_state(), (true, 1)); let end = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026l", &tx); assert!(end.request_render); + assert_eq!(pane_terminal.synchronized_output_state(), (false, 2)); } #[test] diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 554e8167..d542896a 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -276,6 +276,12 @@ pub(super) struct RenderedPaneSurface { pub(super) graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, } +#[derive(Debug)] +pub(super) enum SurfaceRenderDeferred { + Synchronized, + Changed, +} + pub(super) fn render_pane_surface( app: &mut app::App, target: Option<crate::ui::TabSurfaceTarget>, @@ -285,36 +291,54 @@ pub(super) fn render_pane_surface( cell_size: crate::kitty_graphics::HostCellSize, graphics_delivery: &crate::kitty_graphics::surface::DeliveryCache, client_id: u64, -) -> RenderedPaneSurface { - let content_revisions_before = target - .and_then(|target| { - let workspace = app.state.workspaces.get(target.workspace_index)?; - let tab = workspace.tabs.get(target.tab_index)?; - Some( - tab.layout - .pane_ids() - .into_iter() - .filter_map(|pane_id| { - app.state - .runtime_for_pane_in_workspace( - &app.terminal_runtimes, - target.workspace_index, - pane_id, - ) - .map(|runtime| (pane_id, runtime.content_seq())) - }) - .collect::<std::collections::HashMap<_, _>>(), - ) - }) - .unwrap_or_default(); +) -> Result<RenderedPaneSurface, SurfaceRenderDeferred> { + let layout = crate::ui::compute_tab_surface_for( + &app.state, + &app.terminal_runtimes, + target, + area, + resize_panes, + cell_size, + ); + let mut content_revisions_before = std::collections::HashMap::new(); + if let Some(target) = target { + for pane in &layout.pane_infos { + if let Some(runtime) = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + target.workspace_index, + pane.id, + ) { + let (synchronized, epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + let revision = runtime.content_seq(); + content_revisions_before.insert(pane.id, (epoch, revision)); + } + } + } + let popup_revision_before = if show_popup { + app.state + .popup_pane + .as_ref() + .and_then(|popup| app.terminal_runtimes.get(&popup.terminal_id)) + .map(|runtime| { + let (synchronized, epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + Ok(epoch) + }) + .transpose()? + } else { + None + }; let (buffer, cursor, hyperlinks, layout) = crate::server::render_stream::render_tab_surface_virtual( &app.state, &app.terminal_runtimes, - target, + layout, area, - resize_panes, - cell_size, ); let panes = target .map(|target| { @@ -343,7 +367,9 @@ pub(super) fn render_pane_surface( }; let content_revision = runtime.map_or(0, |runtime| { let after = runtime.content_seq(); - if content_revisions_before.get(&pane.id).copied() == Some(after) + if content_revisions_before + .get(&pane.id) + .is_some_and(|&(_, before)| before == after) && after.is_multiple_of(2) { after @@ -422,14 +448,47 @@ pub(super) fn render_pane_surface( graphics_delivery, client_id, ); - RenderedPaneSurface { + if let Some(target) = target { + for (&pane_id, &(epoch, _)) in &content_revisions_before { + if let Some(runtime) = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + target.workspace_index, + pane_id, + ) { + let (synchronized, after_epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + if after_epoch != epoch { + return Err(SurfaceRenderDeferred::Changed); + } + } + } + } + if let Some(before) = popup_revision_before { + if let Some(runtime) = app + .state + .popup_pane + .as_ref() + .and_then(|popup| app.terminal_runtimes.get(&popup.terminal_id)) + { + let (synchronized, after_epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + if after_epoch != before { + return Err(SurfaceRenderDeferred::Changed); + } + } + } + Ok(RenderedPaneSurface { frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), panes, splits, popup, graphics, graphics_delivery: next_graphics_delivery, - } + }) } fn render_popup_surface( diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 82564ba3..a3bbedfa 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -441,12 +441,33 @@ impl HeadlessServer { }); if changed { if let Some(target) = self.shell_target_for_client(*client_id) { + let area = Rect::new(0, 0, *cols, *rows); + let layout = crate::ui::compute_tab_surface_for( + &self.app.state, + &self.app.terminal_runtimes, + Some(target), + area, + false, + *cell_size, + ); + if layout.pane_infos.iter().any(|pane| { + self.app + .state + .runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + target.workspace_index, + pane.id, + ) + .is_some_and(|runtime| runtime.synchronized_output_active()) + }) { + continue; + } crate::ui::resize_tab_surface( &self.app.state, &self.app.terminal_runtimes, target.workspace_index, target.tab_index, - Rect::new(0, 0, *cols, *rows), + area, if cell_size.is_known() { *cell_size } else { @@ -473,6 +494,55 @@ impl HeadlessServer { let shell_target = self.shell_target_for_client(client_id); let shell_tab_id = self.shell_tab_id_for_client(client_id); let shell_shows_popup = shell_tab_id.as_deref() == self.popup_owner_tab_id.as_deref(); + let shell_graphics_delivery = self + .clients + .get(&client_id) + .map(|client| client.shell_graphics_delivery.clone()) + .unwrap_or_default(); + let shell_render = if matches!(mode, ClientConnectionMode::ClientShell) + && self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + let render_started = crate::render_prof::timer(); + let render_cell_size = if cell_size.is_known() { + cell_size + } else { + crate::kitty_graphics::HostCellSize::default() + }; + let result = render_client_shell_pane_surface( + &mut self.app, + shell_target, + area, + false, + shell_shows_popup, + render_cell_size, + &shell_graphics_delivery, + client_id, + ); + crate::render_prof::duration_since( + "full_render.render_tab_surface_virtual", + render_started, + ); + match result { + Ok(surface) => Some(surface), + Err(reason) => { + if let Some(client) = self.clients.get_mut(&client_id) { + client.render_state.request_recompute(); + } + if matches!( + reason, + crate::server::client_shell::SurfaceRenderDeferred::Changed + ) { + self.app.render_dirty.request_generic(); + } + continue; + } + } + } else { + None + }; let mut shell_projection_revision = 0; if matches!(mode, ClientConnectionMode::ClientShell) { let location = self @@ -585,20 +655,9 @@ impl HeadlessServer { continue; } } - let shell_graphics_delivery = self - .clients - .get(&client_id) - .map(|client| client.shell_graphics_delivery.clone()) - .unwrap_or_default(); let mut surface_parts = None; let frame = match mode { ClientConnectionMode::ClientShell => { - let render_started = crate::render_prof::timer(); - let render_cell_size = if cell_size.is_known() { - cell_size - } else { - crate::kitty_graphics::HostCellSize::default() - }; let crate::server::client_shell::RenderedPaneSurface { frame, panes, @@ -606,20 +665,7 @@ impl HeadlessServer { popup, graphics, graphics_delivery: next_graphics_delivery, - } = render_client_shell_pane_surface( - &mut self.app, - shell_target, - area, - false, - shell_shows_popup, - render_cell_size, - &shell_graphics_delivery, - client_id, - ); - crate::render_prof::duration_since( - "full_render.render_tab_surface_virtual", - render_started, - ); + } = shell_render.expect("active shell surface"); surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery)); frame } @@ -638,6 +684,13 @@ impl HeadlessServer { broken_clients.push(client_id); continue; }; + let (synchronized, epoch) = runtime.synchronized_output_state(); + if synchronized { + if let Some(client) = self.clients.get_mut(&client_id) { + client.render_state.request_recompute(); + } + continue; + } let render_started = crate::render_prof::timer(); let (buffer, cursor) = crate::server::render_stream::render_terminal_virtual(runtime, area); @@ -651,6 +704,16 @@ impl HeadlessServer { "full_render.visible_hyperlinks", hyperlinks_started, ); + let (synchronized, after_epoch) = runtime.synchronized_output_state(); + if synchronized || after_epoch != epoch { + if let Some(client) = self.clients.get_mut(&client_id) { + client.render_state.request_recompute(); + } + if !synchronized { + self.app.render_dirty.request_generic(); + } + continue; + } let frame_started = crate::render_prof::timer(); let frame = FrameData::from_ratatui_buffer_with_hyperlinks( &buffer, diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs index 2a637eac..07d33ef5 100644 --- a/src/server/headless/retained_surface.rs +++ b/src/server/headless/retained_surface.rs @@ -206,6 +206,20 @@ struct RetainedRecipientUpdate { )>, } +fn has_synchronized_pane(app: &app::App, surface: &protocol::PaneSurfaceFrame) -> bool { + surface.panes.iter().any(|pane| { + app.parse_pane_id(&pane.pane_id) + .and_then(|(workspace_index, pane_id)| { + app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + ) + }) + .is_some_and(|runtime| runtime.synchronized_output_active()) + }) +} + impl HeadlessServer { /// Applies terminal dirty rows to the committed origin-relative pane surface. /// Any presentation or geometry uncertainty falls back to the complete renderer. @@ -281,6 +295,9 @@ impl HeadlessServer { { fallback!("baseline_mismatch"); } + if has_synchronized_pane(&self.app, surface) { + fallback!("synchronized_visible"); + } recipients.push(RetainedRecipient { client_id: *client_id, surface, @@ -346,7 +363,7 @@ impl HeadlessServer { } let mut updates = Vec::with_capacity(recipients.len()); - for recipient in recipients { + for recipient in &recipients { let client_id = recipient.client_id; let surface = recipient.surface; let mut panes = surface.panes.clone(); @@ -459,6 +476,12 @@ impl HeadlessServer { if updates.is_empty() { success!("unchanged"); } + if recipients + .iter() + .any(|recipient| has_synchronized_pane(&self.app, recipient.surface)) + { + fallback!("synchronized_during_patch"); + } let mut sent = 0u64; let mut deferred = 0u64; diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 5f54f4b6..84031ce4 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1262,6 +1262,147 @@ fn recv_pane_surface_patch( } } +#[tokio::test] +async fn unrelated_render_keeps_synchronized_pane_frame_committed() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (_control, render) = connect_matching_test_shell(&mut server, 7); + server.render_and_stream(); + let before = recv_pane_surface(&render, "baseline"); + assert!(frame_text(&before.frame).contains("BASE")); + let projection_before = server.clients[&7].shell_projection_revision; + + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b[?2026h\x1b[?1049h\x1b[2J\x1b[HPARTIAL", + ); + server.app.state.workspaces[0].custom_name = Some("renamed during frame".into()); + server.clients.get_mut(&7).unwrap().request_recompute(); + server.render_and_stream(); + assert!(render.try_recv().is_err(), "partial frame was published"); + assert_eq!( + server.clients[&7].shell_projection_revision, + projection_before + ); + + write_shared_test_pane(&mut server, pane_id, b"\rCOMPLETE\x1b[?2026l"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + server.render_and_stream(); + let after = recv_pane_surface(&render, "completed frame"); + assert!(frame_text(&after.frame).contains("COMPLETE")); + assert!(after.projection_revision > projection_before); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn sibling_retained_output_waits_for_synchronized_pane_to_finish() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("synchronized-split"); + let first = workspace.tabs[0].root_pane; + let second = workspace.test_split(ratatui::layout::Direction::Vertical); + workspace.insert_test_runtime( + first, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"FIRST"), + ); + workspace.insert_test_runtime( + second, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"SECOND"), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let (_control, render) = connect_matching_test_shell(&mut server, 7); + server.render_and_stream(); + let _ = recv_pane_surface(&render, "split baseline"); + + write_shared_test_pane(&mut server, first, b"\x1b[?2026h\rPARTIAL"); + write_shared_test_pane(&mut server, second, b"\rUPDATED"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([second]))); + server.render_and_stream(); + assert!( + render.try_recv().is_err(), + "sibling published partial frame" + ); + + write_shared_test_pane(&mut server, first, b"\rCOMPLETE\x1b[?2026l"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([first]))); + server.render_and_stream(); + let after = recv_pane_surface(&render, "completed split"); + let text = frame_text(&after.frame); + assert!( + text.contains("COMPLETE") && text.contains("UPDATED"), + "{text}" + ); + assert!(!text.contains("PARTIAL")); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn popup_synchronized_output_waits_for_complete_frame() { + let mut server = test_headless_server(); + install_shared_view_test_runtime(&mut server); + let popup_runtime = + crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"POPUP_BASE"); + let (_, popup_id) = server.app.install_test_popup_runtime(popup_runtime); + server.popup_owner_tab_id = server.app.public_tab_id(0, 0); + let (_control, render) = connect_matching_test_shell(&mut server, 7); + server.render_and_stream(); + assert!(recv_pane_surface(&render, "popup baseline").popup.is_some()); + + server + .app + .terminal_runtimes + .get(&popup_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2026h\rPOPUP_PARTIAL"); + server.render_and_stream(); + assert!(render.try_recv().is_err(), "partial popup was published"); + + server + .app + .terminal_runtimes + .get(&popup_id) + .unwrap() + .test_process_pty_bytes(b"\rPOPUP_COMPLETE\x1b[?2026l"); + server.render_and_stream(); + let after = recv_pane_surface(&render, "complete popup"); + assert!(after + .popup + .as_ref() + .is_some_and(|popup| frame_text(&popup.frame).contains("POPUP_COMPLETE"))); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn zoom_hidden_synchronized_pane_does_not_block_surface() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("zoomed-sync"); + let hidden = workspace.tabs[0].root_pane; + let visible = workspace.test_split(ratatui::layout::Direction::Vertical); + workspace.tabs[0].zoomed = true; + workspace.insert_test_runtime( + hidden, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"HIDDEN"), + ); + workspace.insert_test_runtime( + visible, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"VISIBLE"), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let (_control, render) = connect_matching_test_shell(&mut server, 7); + write_shared_test_pane(&mut server, hidden, b"\x1b[?2026h\rPARTIAL"); + server.render_and_stream(); + let surface = recv_pane_surface(&render, "zoomed visible pane"); + assert!(frame_text(&surface.frame).contains("VISIBLE")); + assert!(!frame_text(&surface.frame).contains("PARTIAL")); + shutdown_test_runtimes(&mut server); +} + #[tokio::test] async fn retained_snapshot_survives_a_writer_waiting_for_the_terminal_core() { let mut server = test_headless_server(); @@ -3830,6 +3971,60 @@ fn with_terminal_session_test_server( rt.shutdown_timeout(Duration::from_millis(100)); } +#[test] +fn terminal_observers_wait_for_synchronized_output_with_or_without_baseline() { + with_terminal_session_test_server(|server, terminal_id, target, _| { + let connect = |server: &mut HeadlessServer, client_id| { + let (writer, _control, render) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id, + target: target.clone(), + }) + ); + render + }; + let first = connect(server, 7); + server.render_and_stream(); + let _ = first.recv().expect("observer baseline"); + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2026h\rPARTIAL"); + let second = connect(server, 8); + server.render_and_stream(); + assert!(first.try_recv().is_err()); + assert!(second.try_recv().is_err()); + + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\rCOMPLETE\x1b[?2026l"); + server.render_and_stream(); + for frames in [&first, &second] { + let ServerMessage::Terminal(frame) = + read_server_message(frames.recv().expect("complete observer frame")) + else { + panic!("expected terminal frame"); + }; + assert!(String::from_utf8_lossy(&frame.bytes).contains("COMPLETE")); + } + }); +} + fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); } diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index dcafa1a9..f4bb310b 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -95,7 +95,8 @@ impl RenderPipeline { }, &self.graphics_delivery, 1, - ); + ) + .expect("benchmark surface"); let server_elapsed = started.elapsed(); self.graphics_delivery = rendered.graphics_delivery; @@ -469,7 +470,8 @@ fn print_surface_damage_profiles() { HostCellSize::default(), &pipeline.graphics_delivery, 1, - ); + ) + .expect("benchmark surface"); let mut surface = PaneSurfaceFrame { boot_id: "bench-boot".into(), projection_revision: 1, @@ -520,7 +522,8 @@ fn print_surface_reuse_profiles() { HostCellSize::default(), &pipeline.graphics_delivery, 1, - ); + ) + .expect("benchmark surface"); let surface = PaneSurfaceFrame { boot_id: "bench-boot".into(), projection_revision: 1, diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index b0af26d7..db613e96 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -472,19 +472,9 @@ pub(crate) type RenderedTabSurface = ( pub(crate) fn render_tab_surface_virtual( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, - target: Option<crate::ui::TabSurfaceTarget>, + layout: crate::ui::TabSurfaceLayout, area: Rect, - resize_panes: bool, - cell_size: crate::kitty_graphics::HostCellSize, ) -> RenderedTabSurface { - let layout = crate::ui::compute_tab_surface_for( - app_state, - terminal_runtimes, - target, - area, - resize_panes, - cell_size, - ); let surface = crate::ui::TabSurfaceView { target: layout.target, pane_infos: &layout.pane_infos, diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 7d5542a6..3a460312 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -352,6 +352,10 @@ impl TerminalRuntime { self.0.synchronized_output_active() } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.0.synchronized_output_state() + } + pub fn visible_text(&self) -> String { self.0.visible_text() } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index be9df8da..adf1f3d3 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -869,17 +869,21 @@ mod tests { let terminal_id = app.workspaces[0].terminal_id(pane_id).unwrap().clone(); app.terminals.get_mut(&terminal_id).unwrap().restore_error = Some("Saved directory is unavailable. Restart to retry.".into()); - let (buffer, cursor, _, _) = crate::server::render_stream::render_tab_surface_virtual( + let runtimes = TerminalRuntimeRegistry::new(); + let area = Rect::new(0, 0, 80, 24); + let layout = crate::ui::compute_tab_surface_for( &app, - &TerminalRuntimeRegistry::new(), + &runtimes, Some(crate::ui::TabSurfaceTarget { workspace_index: 0, tab_index: 0, }), - Rect::new(0, 0, 80, 24), + area, false, Default::default(), ); + let (buffer, cursor, _, _) = + crate::server::render_stream::render_tab_surface_virtual(&app, &runtimes, layout, area); let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); assert!(text.contains("Saved directory is unavailable.")); assert!(cursor.is_none_or(|cursor| !cursor.visible)); From 23479dd162caba32379d405b8c8107ff2ae8dbaf Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Wed, 23 Sep 2026 04:54:44 +0300 Subject: [PATCH 51/99] fix: prevent Windows non-text keys from triggering the space prefix (#4510) refs #4470 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> --- src/client/input/windows_vti.rs | 37 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 2b92c167..0deb3185 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -1353,7 +1353,9 @@ fn ctrl_key_code(vk: u16, u: u16, oem: Option<char>) -> Option<crate::protocol:: use crate::protocol::ClientKeyCode; Some(match (vk, u) { (0xbf, 0x00) => ClientKeyCode::Char(oem?), - (_, 0x00) => ClientKeyCode::Char(' '), + // Keep Ctrl+Break and Ctrl+Space mappings. Other physical keys carrying + // Unicode zero are not evidence of a Space character. + (0x03 | 0x20, 0x00) => ClientKeyCode::Char(' '), (_, 0x1b) => ClientKeyCode::Char('['), (_, 0x1c) => ClientKeyCode::Char('\\'), (_, 0x1d) => ClientKeyCode::Char(']'), @@ -1595,6 +1597,30 @@ mod tests { .collect() } + #[test] + fn vti_ctrl_win_does_not_emit_ctrl_space() { + // Exact host sequences captured in issue #4470: Ctrl down, Win down, + // Ctrl up, Win up. Non-text keys must not become the Space prefix. + let input = + "\x1b[17;29;0;1;8;1_\x1b[91;91;0;1;264;1_\x1b[17;29;0;0;0;1_\x1b[91;91;0;0;256;1_"; + let mut translator = WindowsInputTranslator::default(); + let events: Vec<_> = input + .chars() + .flat_map(|ch| translator.translate(key_char(ch))) + .collect(); + assert!(events.is_empty(), "unexpected input events: {events:?}"); + assert!(translator.idle().is_empty()); + + // Ignoring the chord must not consume or defer the following input. + for record in [key_char('c'), key_vk_with_utf16_mods(0x20, 0, 0x0008)] { + assert_eq!( + translator.translate(record), + translate_with_provenance([record]) + ); + } + assert!(translator.idle().is_empty()); + } + #[cfg(windows)] #[test] fn windows_input_trace_preserves_mapped_event_groups() { @@ -1932,6 +1958,7 @@ mod tests { #[test] fn vti_control_records_keep_physical_digit_identity() { let cases = [ + (0x03, 0x00, ' '), (0x20, 0x00, ' '), (0x31, 0x00, '1'), (0xdc, 0x1c, '\\'), @@ -2125,14 +2152,6 @@ mod tests { ); } - #[test] - fn vti_ctrl_break_record_keeps_semantic_path() { - assert!(matches!( - translate_with_provenance([key_vk(0x03, 0x0008)]).as_slice(), - [crate::protocol::ClientInputEvent::Key { .. }] - )); - } - #[test] fn vti_ime_virtual_keys_keep_semantic_path() { for vk in [0xe5, 0xe7] { From 9903007e5c4407ded451cac0b8164e3d69deb891 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Wed, 23 Sep 2026 14:26:17 +0200 Subject: [PATCH 52/99] test(windows): capture native console input records (#4520) refs #4470 --- scripts/windows_input/Native.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs index 42b387b1..76cb8af7 100644 --- a/scripts/windows_input/Native.cs +++ b/scripts/windows_input/Native.cs @@ -389,7 +389,7 @@ public sealed class ConsoleProbe : IDisposable { [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,Record[] records,uint size,out uint count); + [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); @@ -459,7 +459,11 @@ void Read() { public void Clear() { lock(gate) { bytes.Clear(); records.Clear(); } } public bool ClearIfCount(int expected) { lock(gate) { - if(bytes.Count+records.Count!=expected) return false; + if(bytes.Count+records.Count!=expected) { + if(!native || bytes.Count!=0 || records.Count<expected) return false; + for(int i=expected;i<records.Count;i++) + if(records[i][0]!=4 && records[i][0]!=16) return false; // resize/focus between captures + } bytes.Clear(); records.Clear(); return true; } } From c958833fa81333bf07a255f7d1325e6892daca1e Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Wed, 23 Sep 2026 22:44:56 +0800 Subject: [PATCH 53/99] test: wait for complete handoff pid markers (#4538) * test: wait for complete handoff pid markers * test: wait for complete handoff pid markers --- tests/live_handoff.rs | 65 ++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index ea4b84df..3420940c 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -410,6 +410,50 @@ fn wait_for_file_contains(path: &Path, needle: &str, timeout: Duration) -> Strin ); } +fn wait_for_pid_marker(path: &Path, timeout: Duration) -> u32 { + // Shell redirection creates the file before echo writes the PID. Wait for + // the newline too, so a partially written PID cannot be accepted. + let text = wait_for_file_contains(path, "\n", timeout); + text.lines() + .next() + .and_then(|line| line.split_whitespace().last()) + .and_then(|pid| pid.parse().ok()) + .filter(|pid| *pid > 0) + .unwrap_or_else(|| panic!("invalid PID marker at {}: {text:?}", path.display())) +} + +#[test] +fn pid_marker_waits_for_complete_line() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let marker = base.join("child.pid"); + // Keep each incomplete marker unchanged throughout the wait. In particular, + // READY 12 must time out rather than return a truncated but parseable PID. + for partial in ["", "READY ", "READY 12"] { + fs::write(&marker, partial).unwrap(); + let timeout = Duration::from_millis(100); + let started = Instant::now(); + let panic = std::panic::catch_unwind(|| wait_for_pid_marker(&marker, timeout)) + .expect_err("incomplete marker should time out"); + assert!( + started.elapsed() >= timeout, + "marker {partial:?} failed early" + ); + let message = panic.downcast_ref::<String>().expect("timeout diagnostic"); + assert_eq!( + message, + &format!( + "{} did not contain {:?}; last text was {partial:?}", + marker.display(), + "\n" + ) + ); + } + fs::write(&marker, "READY 1234\n").unwrap(); + assert_eq!(wait_for_pid_marker(&marker, Duration::from_secs(1)), 1234); + fs::remove_dir_all(base).unwrap(); +} + #[cfg(target_os = "linux")] fn server_ptmx_fd_count(pid: u32) -> usize { let Ok(entries) = fs::read_dir(format!("/proc/{pid}/fd")) else { @@ -1014,17 +1058,8 @@ fn live_handoff_preserves_pane_process_io() { "params": {"pane_id": second_pane_id, "text": second_command, "keys": ["Enter"]} }), )); - support::wait_for_file(&marker, Duration::from_secs(5)); - support::wait_for_file(&second_marker, Duration::from_secs(5)); - let pid_text = fs::read_to_string(&marker).unwrap(); - let child_pid: u32 = pid_text.split_whitespace().last().unwrap().parse().unwrap(); - let second_pid_text = fs::read_to_string(&second_marker).unwrap(); - let second_child_pid: u32 = second_pid_text - .split_whitespace() - .last() - .unwrap() - .parse() - .unwrap(); + let child_pid = wait_for_pid_marker(&marker, Duration::from_secs(5)); + let second_child_pid = wait_for_pid_marker(&second_marker, Duration::from_secs(5)); assert_eq!(unsafe { libc::kill(child_pid as libc::pid_t, 0) }, 0); assert_eq!(unsafe { libc::kill(second_child_pid as libc::pid_t, 0) }, 0); @@ -1955,9 +1990,7 @@ fn live_handoff_bad_expected_protocol_rolls_back_old_server() { "params": {"pane_id": pane_id, "text": command, "keys": ["Enter"]} }), )); - support::wait_for_file(&marker, Duration::from_secs(5)); - let pid_text = fs::read_to_string(&marker).unwrap(); - let child_pid: u32 = pid_text.split_whitespace().last().unwrap().parse().unwrap(); + let child_pid = wait_for_pid_marker(&marker, Duration::from_secs(5)); let failed = request( &api_socket, @@ -2041,9 +2074,7 @@ fn live_handoff_import_failure_rolls_back_old_server_at(failure_point: &str) { "params": {"pane_id": pane_id, "text": command, "keys": ["Enter"]} }), )); - support::wait_for_file(&marker, Duration::from_secs(5)); - let pid_text = fs::read_to_string(&marker).unwrap(); - let child_pid: u32 = pid_text.split_whitespace().last().unwrap().parse().unwrap(); + let child_pid = wait_for_pid_marker(&marker, Duration::from_secs(5)); let failed = request( &api_socket, From 21fd121a63db53ff44b947bbe38c47bbf35e632c Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Wed, 23 Sep 2026 20:39:01 +0200 Subject: [PATCH 54/99] fix: prevent cursor flicker on status redraw (#4554) * fix: prevent cursor flicker on status redraw * fix: avoid stale cursor during resize --- src/pane/cursor.rs | 140 +++++++++++++++++++++++++++++++++++++++---- src/pane/terminal.rs | 30 ++++++++++ 2 files changed, 159 insertions(+), 11 deletions(-) diff --git a/src/pane/cursor.rs b/src/pane/cursor.rs index a7886259..2ba94312 100644 --- a/src/pane/cursor.rs +++ b/src/pane/cursor.rs @@ -106,19 +106,20 @@ impl CursorPositionSettleState { // when typing advanced its column. Otherwise the accumulated typing // deadline can settle a later repair cell and anchor redraws there. if let (Some(candidate), Some(since)) = (self.candidate, self.candidate_since) { + let expired = now.duration_since(since) >= self.candidate_hold(); let restored = self.settled.zip(current).is_some_and(|(settled, current)| { settled.visible + && current.visible + && (candidate.visible || !expired) && (same_cursor_position(settled, current) - || (candidate.y != settled.y - && current.y == settled.y - && now.duration_since(since) < self.candidate_hold())) + || (candidate.y != settled.y && current.y == settled.y && !expired)) }); if restored { self.settle(current); return; } // Preserve an eligible caret before a later redraw moves it away. - if now.duration_since(since) >= self.candidate_hold() { + if expired { self.settle(Some(candidate)); } } @@ -127,9 +128,29 @@ impl CursorPositionSettleState { return; }; if !current.visible { + // A PTY can briefly hide a stationary caret during a redraw. + // Keep the last visible cell until the existing max hold expires. + if self.candidate.is_some_and(|candidate| { + !candidate.visible && same_cursor_position(candidate, current) + }) { + return; + } + if self.candidate.is_none() + && self.settled.is_some_and(|settled| { + settled.visible && same_cursor_position(settled, current) + }) + { + self.candidate = Some(current); + self.pending_since = Some(now); + self.candidate_since = Some(now); + return; + } self.settle(Some(current)); return; } + if self.candidate.is_some_and(|candidate| !candidate.visible) { + self.settle(self.settled); + } let Some(settled) = self.settled else { self.settle(Some(current)); return; @@ -184,7 +205,9 @@ impl CursorPositionSettleState { } self.settled .map(|settled| TerminalCursorState { - visible: current.visible && settled.visible, + visible: settled.visible + && (current.visible + || (!candidate.visible && same_cursor_position(candidate, current))), shape: current.shape, ..settled }) @@ -204,7 +227,7 @@ impl CursorPositionSettleState { } fn candidate_hold(&self) -> Duration { - if self.candidate_jump { + if self.candidate_jump || self.candidate.is_some_and(|candidate| !candidate.visible) { CURSOR_POSITION_MAX_HOLD } else { CURSOR_POSITION_SETTLE @@ -499,7 +522,7 @@ mod tests { } #[test] - fn cursor_settle_hides_immediately_and_waits_to_reveal() { + fn cursor_settle_ignores_short_same_position_hides() { let now = Instant::now(); let mut settle = CursorPositionSettleState::default(); settle.observe(Some(cursor(1, 0, true, 0)), now); @@ -507,13 +530,108 @@ mod tests { assert_eq!( settle.reported_cursor(Some(cursor(1, 0, false, 0)), now + Duration::from_millis(2)), - Some(cursor(1, 0, false, 0)) + Some(cursor(1, 0, true, 0)) + ); + settle.observe( + Some(cursor(1, 0, false, 0)), + now + Duration::from_millis(50), + ); + assert_eq!( + settle.reported_cursor( + Some(cursor(1, 0, false, 0)), + now + Duration::from_millis(90) + ), + Some(cursor(1, 0, true, 0)) + ); + assert!( + !settle + .reported_cursor( + Some(cursor(2, 0, false, 0)), + now + Duration::from_millis(90) + ) + .unwrap() + .visible + ); + settle.observe(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(91)); + assert_eq!( + settle.reported_cursor(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(92)), + Some(cursor(1, 0, true, 0)) + ); + assert!(!settle.pending()); + } + + #[test] + fn cursor_settle_hides_after_deadline_and_waits_to_reveal() { + let now = Instant::now(); + let mut settle = CursorPositionSettleState::default(); + let visible = cursor(1, 0, true, 0); + let hidden = cursor(1, 0, false, 0); + settle.observe(Some(visible), now); + settle.observe(Some(hidden), now + Duration::from_millis(1)); + assert_eq!(settle.render_delay(), Some(CURSOR_POSITION_MAX_HOLD)); + assert_eq!( + settle.reported_cursor(Some(hidden), now + Duration::from_millis(100)), + Some(visible) + ); + assert_eq!( + settle.reported_cursor(Some(hidden), now + Duration::from_millis(101)), + Some(hidden) + ); + + // The pure read above exposes expiry without changing the state. + settle.observe(Some(visible), now + Duration::from_millis(102)); + assert_eq!( + settle.reported_cursor(Some(visible), now + Duration::from_millis(103)), + Some(hidden) + ); + assert_eq!( + settle.reported_cursor(Some(visible), now + Duration::from_millis(122)), + Some(visible) + ); + } + + #[test] + fn cursor_settle_hides_immediately_outside_stationary_caret() { + let now = Instant::now(); + let visible = cursor(1, 0, true, 0); + let hidden = cursor(2, 0, false, 0); + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(visible), now); + settle.observe(Some(hidden), now + Duration::from_millis(1)); + assert_eq!( + settle.reported_cursor(Some(hidden), now + Duration::from_millis(2)), + Some(hidden) + ); + + settle.observe(None, now + Duration::from_millis(3)); + assert_eq!( + settle.reported_cursor(None, now + Duration::from_millis(4)), + None ); - settle.observe(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(3)); + for hide_at in [visible, cursor(2, 0, true, 0)] { + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(visible), now); + settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1)); + let hidden = TerminalCursorState { + visible: false, + ..hide_at + }; + settle.observe(Some(hidden), now + Duration::from_millis(2)); + assert_eq!( + settle.reported_cursor(Some(hidden), now + Duration::from_millis(3)), + Some(hidden) + ); + } + + // Once the pending move has expired, its destination is the caret to retain. + let mut settle = CursorPositionSettleState::default(); + settle.observe(Some(visible), now); + settle.observe(Some(cursor(2, 0, true, 0)), now + Duration::from_millis(1)); + settle.observe(Some(hidden), now + Duration::from_millis(22)); assert_eq!( - settle.reported_cursor(Some(cursor(1, 0, true, 0)), now + Duration::from_millis(4)), - Some(cursor(1, 0, false, 0)) + settle.reported_cursor(Some(hidden), now + Duration::from_millis(23)), + Some(cursor(2, 0, true, 0)) ); } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index e2005a8f..66c23c26 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -4414,6 +4414,36 @@ mod tests { ); } + #[test] + #[cfg(windows)] + fn cursor_state_holds_brief_pty_hide_and_schedules_sustained_hide() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.process_pty_bytes(pane_id, 0, b"\x1b[15;4H", &tx); + { + let mut core = pane.core.lock().unwrap(); + let current = current_cursor_state(&mut core); + core.cursor_settle_state = CursorPositionSettleState::default(); + core.cursor_settle_state.observe(current, Instant::now()); + } + + let result = pane.process_pty_bytes(pane_id, 0, b"\x1b[?25l", &tx); + assert!(result.request_render); + assert_eq!(result.render_delay, Some(Duration::from_millis(100))); + assert!(pane.cursor_state().is_some_and(|cursor| cursor.visible)); + let mut core = pane.core.lock().unwrap(); + let current = current_cursor_state(&mut core); + assert!( + !core + .cursor_settle_state + .reported_cursor(current, Instant::now() + result.render_delay.unwrap()) + .unwrap() + .visible + ); + } + #[test] #[cfg(windows)] fn cursor_settle_ignores_intermediate_synchronized_frame_positions() { From 9c96f7ddb3be2cc575a159d4d1f1d49fb10d7006 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 24 Sep 2026 02:23:42 +0200 Subject: [PATCH 55/99] fix: avoid inferring codex idle from terminal output (#4563) refs #4507 --- distribution/agent-detection/codex.toml | 16 +- docs/next/website/src/content/docs/agents.mdx | 6 +- src/app/actions.rs | 42 ++++ src/cli/agent.rs | 6 + src/detect/manifest.rs | 27 +-- src/detect/manifest/tests.rs | 20 +- src/detect/manifests/codex.toml | 16 +- src/detect/mod.rs | 4 - src/events.rs | 2 + src/pane.rs | 211 +++++++++++++++--- src/pane/agent_detection.rs | 54 ++++- src/terminal/state.rs | 82 ++++++- 12 files changed, 390 insertions(+), 96 deletions(-) diff --git a/distribution/agent-detection/codex.toml b/distribution/agent-detection/codex.toml index ecef12b7..dd275758 100644 --- a/distribution/agent-detection/codex.toml +++ b/distribution/agent-detection/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.22.1" +version = "2026.09.23.1" min_engine_version = 3 -updated_at = "2026-09-22T00:00:00Z" +updated_at = "2026-09-23T22:12:00Z" [[rules]] id = "osc_title_blocked" @@ -92,15 +92,3 @@ 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 ]+\)$'] }] - -[[rules]] -id = "osc_title_idle" -state = "idle" -priority = 100 -region = "osc_title" -visible_idle = true -regex = ['\S'] -not = [ - { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, - { contains = ["Action Required"] }, -] diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index d0612c3f..aaf6b5fb 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -58,9 +58,11 @@ Some restricted Linux runtimes do not expose a terminal foreground process group ## Blocked state -Blocked detection is deliberately strict for screen-manifest agents. Herdr only marks `blocked` when the live bottom-buffer snapshot matches known visible approval, question, or permission UI. If no manifest rule matches for a known agent, Herdr falls back to `idle` and labels that fallback as `default_known_agent_idle_fallback` in explain output. +Blocked detection is deliberately strict for screen-manifest agents. Herdr only marks `blocked` when the live bottom-buffer snapshot matches known visible approval, question, or permission UI. If no manifest rule matches for a known agent other than Codex, Herdr falls back to `idle` and labels that fallback as `default_known_agent_idle_fallback` in explain output. Codex falls back to `unknown` because its title and composer can look the same during an active turn and after a response. -This means unusual new agent prompts may initially show as `idle` instead of `blocked` until Herdr learns that screen shape. The misclassification affects only the visible status and waits. It should not make Herdr send input or take destructive action. +For those other agents, unusual new prompts may initially show as `idle` instead of `blocked` until Herdr learns that screen shape. The misclassification affects only the visible status and waits. It should not make Herdr send input or take destructive action. + +For Codex, a visible spinner or live activity timer can establish `working`, and a visible approval prompt can establish `blocked`. An ordinary title, composer, or missing spinner cannot establish that a turn ended. Codex may therefore stay `unknown` after a response, and waits for `idle` or completion may time out. Managed startup uses the initial composer only to determine when it can accept a prompt; that observation does not change turn status. ## Detection manifests diff --git a/src/app/actions.rs b/src/app/actions.rs index e1bedd26..26bdf0e4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1442,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, @@ -3123,6 +3129,42 @@ mod tests { } } + #[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: 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()); + } + #[test] fn completion_guard_same_state_agent_replacement_clears_old_work() { let mut app = app_with_workspaces(&["active", "background"]); 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/detect/manifest.rs b/src/detect/manifest.rs index acd6b8f9..e224adfe 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -416,23 +416,6 @@ pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionEx explain(agent, screen_content) } -pub fn should_skip_state_update(agent: Agent, screen_content: &str) -> bool { - let Some(loaded) = load_manifest(agent) else { - return false; - }; - evaluate_loaded_manifest( - agent, - DetectionInput { - screen: screen_content, - osc_title: "", - osc_progress: "", - }, - loaded, - false, - ) - .skip_state_update -} - impl DetectionExplain { fn into_detection(self) -> AgentDetection { AgentDetection { @@ -557,14 +540,14 @@ fn fallback_explain( ) }) .unwrap_or((None, Vec::new(), None, None, None, false)); - let known_agent = agent.is_some(); + let assume_idle = agent.is_some_and(|agent| agent != Agent::Codex); let remote_update_status = include_update_status .then(|| agent.and_then(remote_update_status)) .flatten(); DetectionExplain { agent: agent.map(|agent| agent_label(agent).to_string()), - state: if known_agent { + state: if assume_idle { AgentState::Idle } else { AgentState::Unknown @@ -577,7 +560,11 @@ fn fallback_explain( visible_working: false, skip_state_update: false, skipped_update_reason: None, - fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), + fallback_reason: match agent { + Some(Agent::Codex) => Some("codex_state_ambiguous".to_string()), + Some(_) => Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), + None => None, + }, evaluated_rules, warning, manifest_version, diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 07e9aa38..1e6b1a85 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -89,15 +89,21 @@ fn write_local_codex(content: &str) { } #[test] -fn known_agent_no_match_defaults_to_idle_fallback() { +fn codex_no_match_is_unknown_without_changing_other_agents() { with_manifest_dirs("no-match", || { write_local_codex(&local_manifest("working", "active-marker")); let explain = explain(Agent::Codex, "unmatched-marker"); - assert_eq!(explain.state, AgentState::Idle); + assert_eq!(explain.state, AgentState::Unknown); assert!(!explain.visible_idle); assert_eq!( explain.fallback_reason.as_deref(), + Some("codex_state_ambiguous") + ); + let other = fallback_explain(Some(Agent::Pi), None, false); + assert_eq!(other.state, AgentState::Idle); + assert_eq!( + other.fallback_reason.as_deref(), Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) ); }); @@ -184,10 +190,10 @@ fn fallback_explain_preserves_active_manifest_version() { let explain = explain(Agent::Codex, "ordinary prompt text"); - assert_eq!(explain.state, AgentState::Idle); + assert_eq!(explain.state, AgentState::Unknown); assert_eq!( explain.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + Some("codex_state_ambiguous") ); assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1")); assert!(matches!( @@ -267,10 +273,10 @@ fn detection_uses_cached_manifest_until_explicit_reload() { write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready")); let unchanged = explain(Agent::Codex, "new-ready"); - assert_eq!(unchanged.state, AgentState::Idle); + assert_eq!(unchanged.state, AgentState::Unknown); assert_eq!( unchanged.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + Some("codex_state_ambiguous") ); assert_eq!( unchanged.cached_remote_version.as_deref(), @@ -479,8 +485,6 @@ contains = ["overlay-marker"] assert!(!result.visible_working); assert!(!result.visible_blocker); assert!(detect(Agent::Codex, screen).skip_state_update); - assert!(should_skip_state_update(Agent::Codex, screen)); - assert!(!should_skip_state_update(Agent::Codex, "activity-marker")); }); } diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index ecef12b7..dd275758 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.22.1" +version = "2026.09.23.1" min_engine_version = 3 -updated_at = "2026-09-22T00:00:00Z" +updated_at = "2026-09-23T22:12:00Z" [[rules]] id = "osc_title_blocked" @@ -92,15 +92,3 @@ 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 ]+\)$'] }] - -[[rules]] -id = "osc_title_idle" -state = "idle" -priority = 100 -region = "osc_title" -visible_idle = true -regex = ['\S'] -not = [ - { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, - { contains = ["Action Required"] }, -] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 3dae571b..408ee669 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -320,10 +320,6 @@ pub fn detect_agent_with_osc( ) } -pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool { - agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content)) -} - pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool { matches!( (source, agent_label), diff --git a/src/events.rs b/src/events.rs index a614ee0c..0e61f5f8 100644 --- a/src/events.rs +++ b/src/events.rs @@ -89,6 +89,8 @@ pub enum AppEvent { agent: Agent, observed_at: Instant, }, + /// The current Codex input screen is visible during managed startup. + CodexPromptObserved { pane_id: PaneId, ready: bool }, /// Fallback detector state changed in a pane. StateChanged { pane_id: PaneId, diff --git a/src/pane.rs b/src/pane.rs index 9bc35eca..bfeec638 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -34,7 +34,7 @@ mod terminal; mod xtgettcap; use self::agent_detection::{ - decide_detection_screen_read, decide_screen_detection_publish, + codex_prompt_ready, decide_detection_screen_read, decide_screen_detection_publish, detection_update_for_publish_with_osc, mark_detection_content_changed, observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision, DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput, @@ -290,6 +290,31 @@ async fn publish_agent_process_detected_event( } } +async fn publish_codex_prompt_observation( + state_events: &mpsc::Sender<AppEvent>, + pane_id: PaneId, + agent: Option<Agent>, + content: &str, + detection: Option<&crate::detect::AgentDetection>, + process_exited: bool, + last_ready: &mut bool, +) { + let ready = agent == Some(Agent::Codex) + && !process_exited + && detection.is_some_and(|detection| detection.state == AgentState::Unknown) + && codex_prompt_ready(content); + if ready == *last_ready { + return; + } + *last_ready = ready; + if let Err(err) = state_events + .send(AppEvent::CodexPromptObserved { pane_id, ready }) + .await + { + warn!(pane = pane_id.raw(), %err, "failed to deliver Codex prompt observation"); + } +} + #[derive(Debug, Clone, Copy)] struct AgentDetectionPublishUpdate { state: AgentState, @@ -771,6 +796,7 @@ fn spawn_basic_detection_task( let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut last_codex_prompt_ready = false; loop { let sleep_duration = if pending_idle.active() { @@ -781,6 +807,10 @@ fn spawn_basic_detection_task( tokio::select! { _ = tokio::time::sleep(sleep_duration) => {} _ = detect_reset.notified() => { + publish_codex_prompt_observation( + &state_events, pane_id, Some(Agent::Codex), "", None, false, + &mut last_codex_prompt_ready, + ).await; agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_visible_idle = false; @@ -888,6 +918,7 @@ fn spawn_basic_detection_task( == ForegroundShellAgentAction::ReportReplacementProcess; if agent_changed { pending_idle.clear(); + last_codex_prompt_ready = false; last_screen_scan_detection_content_seq = None; // A replacement agent must not inherit OSC evidence // from the previous process; a first acquisition keeps @@ -961,10 +992,29 @@ fn spawn_basic_detection_task( last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); - if !process_exited && crate::detect::should_skip_state_update(agent, &content) { + let osc_title = terminal.agent_osc_title(); + let osc_progress = terminal.agent_osc_progress(); + let screen_detection = detection_update_for_publish_with_osc( + agent, + &content, + &osc_title, + &osc_progress, + process_exited, + ); + publish_codex_prompt_observation( + &state_events, + pane_id, + agent, + &content, + screen_detection.as_ref(), + process_exited, + &mut last_codex_prompt_ready, + ) + .await; + let Some(screen_detection) = screen_detection else { pending_idle.clear(); continue; - } + }; sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, @@ -974,19 +1024,6 @@ fn spawn_basic_detection_task( &mut acquisition_started_at, &mut last_content_change_at, ); - - let osc_title = terminal.agent_osc_title(); - let osc_progress = terminal.agent_osc_progress(); - let Some(screen_detection) = detection_update_for_publish_with_osc( - agent, - &content, - &osc_title, - &osc_progress, - process_exited, - ) else { - pending_idle.clear(); - continue; - }; match decide_screen_detection_publish( ScreenDetectionPublishInput { screen_detection, @@ -2660,6 +2697,7 @@ impl PaneRuntime { let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut last_codex_prompt_ready = false; tokio::time::sleep(Duration::from_millis(50)).await; @@ -2680,6 +2718,10 @@ impl PaneRuntime { tokio::select! { _ = tokio::time::sleep(tick) => {} _ = detect_reset.notified() => { + publish_codex_prompt_observation( + &state_events, pane_id, Some(Agent::Codex), "", None, false, + &mut last_codex_prompt_ready, + ).await; agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_visible_idle = false; @@ -2823,6 +2865,7 @@ impl PaneRuntime { == ForegroundShellAgentAction::ReportReplacementProcess { pending_idle.clear(); + last_codex_prompt_ready = false; last_screen_scan_detection_content_seq = None; // A replacement agent must not inherit OSC // evidence from the previous process; a first @@ -2930,10 +2973,29 @@ impl PaneRuntime { last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); - if detect::should_skip_state_update(agent, &content) { + let osc_title = terminal.agent_osc_title(); + let osc_progress = terminal.agent_osc_progress(); + let screen_detection = detection_update_for_publish_with_osc( + agent, + &content, + &osc_title, + &osc_progress, + process_exited, + ); + publish_codex_prompt_observation( + &state_events, + pane_id, + agent, + &content, + screen_detection.as_ref(), + process_exited, + &mut last_codex_prompt_ready, + ) + .await; + let Some(screen_detection) = screen_detection else { pending_idle.clear(); continue; - } + }; sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, @@ -2943,19 +3005,6 @@ impl PaneRuntime { &mut acquisition_started_at, &mut last_content_change_at, ); - - let osc_title = terminal.agent_osc_title(); - let osc_progress = terminal.agent_osc_progress(); - let Some(screen_detection) = detection_update_for_publish_with_osc( - agent, - &content, - &osc_title, - &osc_progress, - process_exited, - ) else { - pending_idle.clear(); - continue; - }; match decide_screen_detection_publish( ScreenDetectionPublishInput { screen_detection, @@ -5804,4 +5853,104 @@ mod tests { } if delivered_pane == pane_id )); } + + #[tokio::test] + async fn codex_prompt_observation_revokes_on_working_or_skipped_screen() { + let (tx, mut rx) = mpsc::channel(4); + let pane_id = PaneId::from_raw(42); + let detection = crate::detect::AgentDetection { + state: AgentState::Unknown, + skip_state_update: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + }; + let mut last_ready = false; + let prompt = "› Ask Codex to do anything"; + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: true, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(rx.try_recv().is_err()); + let working = crate::detect::AgentDetection { + state: AgentState::Working, + ..detection + }; + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&working), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: false, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: true, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + None, + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: false, .. }) + )); + publish_codex_prompt_observation( + &tx, + pane_id, + Some(Agent::Codex), + prompt, + Some(&detection), + false, + &mut last_ready, + ) + .await; + assert!(matches!( + rx.recv().await, + Some(AppEvent::CodexPromptObserved { ready: true, .. }) + )); + } } diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index 9ef10dfe..e37aec3b 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -89,7 +89,9 @@ pub(super) struct IdleScreenScanSkipInput { } pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bool { - if input.state != AgentState::Idle + let stable_state = input.state == AgentState::Idle + || (input.state == AgentState::Unknown && input.agent == Some(Agent::Codex)); + if !stable_state || input.agent.is_none() || input.pending_idle_active || input.agent_changed @@ -316,6 +318,23 @@ pub(super) fn detection_update_for_publish_with_osc( (!detection.skip_state_update).then_some(detection) } +pub(super) fn codex_prompt_ready(content: &str) -> bool { + // The composer can remain visible during a turn; this is startup evidence only. + let recent: String = content + .lines() + .rev() + .take(12) + .collect::<Vec<_>>() + .into_iter() + .rev() + .flat_map(str::chars) + .filter(|ch| !ch.is_whitespace()) + .collect(); + recent.contains("›AskCodextodoanything") + && !recent.contains("model:loading") + && !recent.contains("Resumingsession") +} + pub(super) fn observe_detection_content_change(bytes: &[u8], detection_content_seq: &AtomicU64) { if !bytes.is_empty() { detection_content_seq.fetch_add(1, Ordering::Relaxed); @@ -379,6 +398,13 @@ mod tests { } } + #[test] + fn codex_startup_prompt_survives_terminal_wraps() { + let wrapped = "header\n› Ask Codex to do\nanything\nfooter"; + assert!(codex_prompt_ready(wrapped)); + assert!(!codex_prompt_ready(&format!("model: load\ning\n{wrapped}"))); + } + #[test] fn screen_read_skips_unchanged_idle_bottom_buffer() { assert_eq!( @@ -387,6 +413,32 @@ mod tests { ); } + #[test] + fn screen_read_skips_unchanged_ambiguous_codex_but_not_new_content_or_replacement() { + let mut input = screen_read_input(AgentState::Unknown, 10); + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Skip + ); + input.current_detection_content_seq = Some(11); + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + input.current_detection_content_seq = Some(10); + input.agent_changed = true; + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + input.agent_changed = false; + input.agent = Some(Agent::Pi); + assert_eq!( + decide_detection_screen_read(input), + DetectionScreenReadDecision::Read + ); + } + #[test] fn screen_read_reads_when_idle_bottom_buffer_changes() { assert_eq!( diff --git a/src/terminal/state.rs b/src/terminal/state.rs index fadd6e10..4a5abb12 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -142,6 +142,7 @@ pub struct TerminalState { pub agent_name: Option<String>, agent_name_owner: Option<AgentNameOwner>, managed_agent: Option<ManagedAgent>, + codex_prompt_ready: bool, managed_agent_launch_session: Option<crate::agent_resume::PersistedAgentSession>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, @@ -179,6 +180,7 @@ impl TerminalState { agent_name: None, agent_name_owner: None, managed_agent: None, + codex_prompt_ready: false, managed_agent_launch_session: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), @@ -217,6 +219,7 @@ impl TerminalState { now, ); if starts_acquisition { + self.codex_prompt_ready = false; self.agent_process_acquisition_pending = true; } mutation @@ -419,6 +422,9 @@ impl TerminalState { }; } self.detected_agent = agent; + if process_exited || agent != Some(Agent::Codex) || fallback_state == AgentState::Blocked { + self.codex_prompt_ready = false; + } if let Some(agent) = agent { let agent_label = crate::detect::agent_label(agent); self.reconcile_agent_name_owner(agent_label, None); @@ -1972,6 +1978,7 @@ impl TerminalState { settle_delay: Duration, timeout: Duration, ) { + self.codex_prompt_ready = false; self.set_agent_name(name); self.agent_process_acquisition_pending = true; self.agent_name_owner = Some(AgentNameOwner { @@ -2002,6 +2009,20 @@ impl TerminalState { .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) } + pub fn observe_codex_prompt_ready(&mut self, ready: bool) -> Option<TerminalStateMutation> { + if self.detected_agent != Some(Agent::Codex) + || self.recent_agent_process_exit.is_some() + || !self.managed_agent.is_some_and(|managed| { + managed.kind == Agent::Codex && managed.phase != ManagedAgentPhase::Active + }) + || self.codex_prompt_ready == ready + { + return None; + } + self.codex_prompt_ready = ready; + Some(TerminalStateMutation::default()) + } + pub fn managed_agent_kind(&self) -> Option<Agent> { self.managed_agent.map(|managed| managed.kind) } @@ -2039,7 +2060,12 @@ impl TerminalState { return true; } if managed.phase == ManagedAgentPhase::Blocked { - if known_agent == Some(managed.kind) && self.state == AgentState::Idle { + if known_agent == Some(managed.kind) + && (self.state == AgentState::Idle + || managed.kind == Agent::Codex + && self.state == AgentState::Unknown + && self.codex_prompt_ready) + { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Active, @@ -2067,7 +2093,12 @@ impl TerminalState { return true; } if ready_after.is_none_or(|ready_after| now >= ready_after) { - if known_agent == Some(managed.kind) && self.state == AgentState::Idle { + if known_agent == Some(managed.kind) + && (self.state == AgentState::Idle + || managed.kind == Agent::Codex + && self.state == AgentState::Unknown + && self.codex_prompt_ready) + { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Active, @@ -2115,6 +2146,7 @@ impl TerminalState { } pub fn clear_agent_name(&mut self) { + self.codex_prompt_ready = false; if self .managed_agent_launch_session .take() @@ -2328,6 +2360,52 @@ mod tests { assert_eq!(terminal.agent_name, None); } + #[test] + fn codex_managed_readiness_requires_current_prompt_without_idle() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::Codex, + now, + Duration::from_millis(100), + Duration::from_secs(1), + ); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Unknown); + terminal.observe_codex_prompt_ready(true); + terminal.reconcile_managed_agent_at(now, false); + assert!(!terminal.managed_agent_interactive_ready()); + terminal.observe_codex_prompt_ready(false); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); + assert!(!terminal.managed_agent_interactive_ready()); + + terminal.observe_codex_prompt_ready(true); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.state, AgentState::Unknown); + terminal.observe_codex_prompt_ready(false); + assert!(terminal.managed_agent_interactive_ready()); + + terminal.begin_managed_agent( + "next".into(), + Agent::Codex, + now, + Duration::ZERO, + Duration::from_secs(1), + ); + terminal.reconcile_managed_agent_at(now, false); + assert!(!terminal.managed_agent_interactive_ready()); + + terminal.set_detected_state(Some(Agent::Codex), AgentState::Blocked); + assert!(terminal.reconcile_managed_agent_at(now, false)); + terminal.observe_codex_prompt_ready(false); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Unknown); + assert!(!terminal.reconcile_managed_agent_at(now, false)); + terminal.observe_codex_prompt_ready(true); + assert!(terminal.reconcile_managed_agent_at(now, false)); + assert!(terminal.managed_agent_interactive_ready()); + } + #[test] fn managed_agent_mismatch_and_timeout_release_name() { let now = Instant::now(); From b7781a67e2d7f89ce07022d8ecdf39687ea552b0 Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Thu, 24 Sep 2026 19:45:59 +0800 Subject: [PATCH 56/99] fix: match navigator search terms independently (#4535) * fix: match navigator search terms independently refs #4273 * docs: remove redundant navigator search note refs #4273 --- src/client/shell/aggregate_navigation.rs | 9 +++++- src/client/shell/tests/copy.rs | 36 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index b1a4d35f..67753e8e 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -288,7 +288,14 @@ pub(super) fn navigator_rows( Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done, None => true, }; - let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query); + let words = query.split_whitespace().collect::<Vec<_>>(); + let text = |value: &str| { + if words.is_empty() { + return true; + } + let value = value.to_lowercase(); + words.iter().all(|word| value.contains(word)) + }; let filtering = navigator.filter.is_some() || !query.is_empty(); let federated = endpoints.len() > 1; let depth_offset = u8::from(federated); diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 617af89b..112417fc 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1080,6 +1080,42 @@ fn navigator_renders_every_terminal_in_workspace_sections() { assert!(visible.iter().all(|row| !row.contains("second"))); } +#[test] +fn navigator_search_matches_non_adjacent_words_without_losing_the_pane_target() { + let mut projected = snapshot(); + projected.panes[0].label = Some("alpha beta gamma".into()); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + for (query, matches) in [ + ("alpha gamma", true), + (" ALP\tGAM ", true), + ("gamma alpha", true), + ("beta gamma", true), + ("alpha missing", false), + ("alphagamma", false), + ] { + navigator.query = query.into(); + navigator.selected = None; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let target = + super::super::aggregate_navigation::selected_navigator_target(&rows, navigator); + assert_eq!( + target, + matches.then(|| ClientNavigatorTarget::Pane { + endpoint_id: state.active_endpoint_id.clone(), + pane_id: "pane_1".into(), + }), + "query={query:?}" + ); + } +} + #[test] fn navigator_searches_ancestor_context_and_keeps_split_agents_individually_actionable() { let mut projected = snapshot(); From d11c0c34cf8f9c2dc0d6fb45c58c307aa5ce8a6b Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 24 Sep 2026 14:29:07 +0200 Subject: [PATCH 57/99] fix: report partial Windows input coverage and clear clipboard for tests (#4566) * fix: report partial Windows input coverage and clear clipboard for tests * fix: require evidence from each Windows input channel * fix: reject clipboard format count errors --- justfile | 2 +- scripts/test_windows_input.ps1 | 16 +++++++++++++++- scripts/test_windows_input.py | 27 +++++++++++++++++++++++++++ scripts/windows_input/Native.cs | 18 +++++++++++++++++- scripts/windows_input/README.md | 19 +++++++++++-------- scripts/windows_input/report.py | 17 +++++++++++------ 6 files changed, 82 insertions(+), 17 deletions(-) diff --git a/justfile b/justfile index f736d8c1..38a45052 100644 --- a/justfile +++ b/justfile @@ -19,7 +19,7 @@ maintenance-test: # 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 {{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: diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index 364ce414..42d23bd7 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -17,6 +17,7 @@ param( [int[]] $Heights = @(24, 50), [string] $OutputDirectory, [switch] $AllowInputInjection, + [switch] $ClearClipboard, [switch] $Manual, [switch] $MatrixOnly ) @@ -47,6 +48,19 @@ if (@($selectedCases | Where-Object id -eq 'mouse-focus-refresh').Count) { 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) { @@ -78,11 +92,11 @@ Write-Host "Herdr under test: $exe" Write-Host "SHA-256: $exeHash" Write-Host "Modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')" Write-Host "Channels: $($Channels -join ', '); paths: $($Paths -join ', '); cases: $(if (@($Cases).Count) { $Cases -join ', ' } else { 'all' })" -Write-Host "Clipboard formats at start: $([HerdrInputGauntlet.Desktop]::CountClipboardFormats()) (paste runs only when zero)" $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; 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' diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py index a844ea62..63329134 100644 --- a/scripts/test_windows_input.py +++ b/scripts/test_windows_input.py @@ -200,6 +200,33 @@ def test_qualification_matrix_requires_every_selected_channel(self): rows = {row[0]: row[1:] for row in qualification_matrix(result)} self.assertEqual(rows["Shift+Enter"][0], "PARTIAL") + 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"}]}], diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs index 76cb8af7..9d635753 100644 --- a/scripts/windows_input/Native.cs +++ b/scripts/windows_input/Native.cs @@ -108,7 +108,15 @@ public static void AssertNotElevated(int pid) { [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")] public static extern int CountClipboardFormats(); + [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); @@ -141,6 +149,14 @@ public static int ActivateApplication(string appUserModelId,string[] arguments) 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"); diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index edebc8fc..61b76bd2 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -33,11 +33,14 @@ or mouse buttons while starting a run. Normal controller cancellation runs clean bootstrap/probe leases also expire if the controller disappears. Lease expiry is not a replacement for a secure isolated desktop. -Clipboard tests require an **empty clipboard**. Clear it yourself only after -saving anything you need. The runner will not replace existing text, images, -rich formats, or files. It writes its synthetic text 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. +`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 @@ -70,7 +73,7 @@ Or invoke the script directly: ```powershell pwsh -NoProfile -File scripts/test_windows_input.ps1 ` - -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection ` + -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection -ClearClipboard ` -StablePath 'C:\TerminalStable\WindowsTerminal.exe' ` -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe' ``` @@ -83,7 +86,7 @@ arrays normally: ```powershell .\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' ` - -AllowInputInjection -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` + -AllowInputInjection -ClearClipboard -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` -Widths 120 -Heights 30 ``` @@ -92,7 +95,7 @@ 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 ` +.\scripts\test_windows_input.ps1 -AllowInputInjection -ClearClipboard ` -Channels stable -Paths direct,herdr-remote -Modes legacy ` -Cases clipboard-image,clipboard-mixed -Widths 80 -Heights 24 ``` diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index cf37f30e..74ce92cd 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -419,18 +419,23 @@ def cell(case_ids, width, path, modes): return "MANUAL" 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" - if required_channels and {(row.get("host"), row.get("case")) for row in matched} != { - (channel, case_id) for channel in required_channels for case_id in case_ids}: + expected_pairs = {(channel, case_id) for channel in required_channels for case_id in case_ids} + if required_channels and {(row.get("host"), row.get("case")) for row in matched} != expected_pairs: 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 == {"unsupported"}: + 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): - statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id} + 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 == {"unsupported"}) + 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 required_channels for case_id in case_ids): + return "PARTIAL" allowed = {"pass", "inconclusive"} if width == 80 and path == "direct" and modes == {"legacy"}: allowed.add("unsupported") From 11a7ab4ae21ab9427bcb44b3be01f62a4ab58c47 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 24 Sep 2026 19:37:09 +0200 Subject: [PATCH 58/99] test: qualify Win32 and Kitty input paths by default (#4587) --- scripts/test_windows_input.ps1 | 27 ++++++-- scripts/test_windows_input.py | 71 +++++++++++++++++++++ scripts/windows_input/README.md | 50 ++++++++------- scripts/windows_input/report.py | 106 +++++++++++++++++++++++++------- 4 files changed, 204 insertions(+), 50 deletions(-) diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index 42d23bd7..4fdeb8ef 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -19,6 +19,7 @@ param( [switch] $AllowInputInjection, [switch] $ClearClipboard, [switch] $Manual, + [switch] $Full, [switch] $MatrixOnly ) . "$PSScriptRoot/windows_input/Common.ps1" @@ -35,6 +36,19 @@ $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' } @@ -90,13 +104,14 @@ $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 "Modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')" -Write-Host "Channels: $($Channels -join ', '); paths: $($Paths -join ', '); cases: $(if (@($Cases).Count) { $Cases -join ', ' } else { 'all' })" +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' @@ -183,7 +198,9 @@ try { $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 ($path in $Paths) { foreach ($mode in $Modes) { + 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 @@ -264,7 +281,7 @@ try { $phase = 0 foreach ($geometry in $geometries) { $phase++ - $selected = if ($geometry.full) { $selectedCases } else { @($selectedCases | Where-Object { $_.id -in @('letter-a', 'shift-enter', 'paste-lf', 'mouse-focus-refresh') }) } + $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 @@ -449,7 +466,7 @@ try { if ($null -ne $launcher) { $launcher.Dispose() } Save-Report } - } } + } } } catch { if ($document.errors.Count -eq 0) { $document.errors += $_.Exception.Message } diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py index 63329134..09b60e2c 100644 --- a/scripts/test_windows_input.py +++ b/scripts/test_windows_input.py @@ -24,6 +24,11 @@ def test_catalogue_has_unique_cases_and_geometry_boundaries(self): 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"]: @@ -200,6 +205,53 @@ def test_qualification_matrix_requires_every_selected_channel(self): 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"))] @@ -241,6 +293,25 @@ def test_herdr_protocol_label_requires_every_run_to_prove_transport(self): 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, diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index 61b76bd2..94b74299 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -16,8 +16,8 @@ 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 at least -one Windows Terminal channel. Stable and Preview are discovered independently +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 @@ -50,19 +50,22 @@ From the repository in PowerShell 7: just test-windows-input ``` -The full catalogue normally exits `2` because operator-assisted and explicitly -unimplemented qualification cases remain. That is incomplete coverage, not an -automated test failure; inspect the printed matrix and retained `report.json`. +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 run builds and identifies the current checkout, reaches the result -matrix, and reports no `FAIL` rows or cleanup errors. The default Herdr column -should identify itself as `Win32 (Herdr)*` from captured runtime evidence. A full -run may still exit `2` and show `MANUAL`, `NOT TESTED`, or a documented host -capability limitation when a terminal channel, physical gesture, or oracle is -unavailable. A focused campaign exits `0` when every selected observation passes; -exit `1` means an assertion, harness, or cleanup failure and needs investigation. +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 @@ -78,11 +81,11 @@ pwsh -NoProfile -File scripts/test_windows_input.ps1 ` -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe' ``` -The default tests current Herdr's **default** input policy. Diagnostic runs may -use `-Profile win32` or `-Profile vt`; they do not replace the default run. -`-Modes native,legacy,mok2,kitty`, `-Channels`, `-Paths`, `-Cases`, `-Widths`, and -`-Heights` select a focused campaign. In a direct PowerShell invocation, supply -arrays normally: +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' ` @@ -140,7 +143,7 @@ Every run needs a **new** output directory. By default it is 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; keyboard/paste sentinels at +- 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 @@ -182,8 +185,10 @@ The retained directory contains: modes, and bootstrap/probe error records. The console ends with a capability matrix derived only from that run's captured -observations. Filtered, unavailable, or operator-assisted cases remain -`NOT TESTED` or `MANUAL`; the full evidence and reasons remain in `report.json`. +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`. @@ -212,8 +217,9 @@ Exit codes: - `0`: all observed assertions passed and no recorded coverage gaps/errors; - `1`: an assertion, harness, or cleanup failed; -- `2`: coverage is incomplete (including deliberately unimplemented catalogue - rows, missing hosts, or unavailable geometry). +- `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. diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index 74ce92cd..f63e700e 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -123,7 +123,14 @@ def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): ("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={})) - return dict(schema=1, widths=WIDTHS, heights=HEIGHTS, modes=MODES, cases=cases) + 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): @@ -362,13 +369,14 @@ def summarize(document): # 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)] - selected_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 phase, (width, height, full) in enumerate(geometries, 1): - for case_id in selected_cases if full else (case_id for case_id in ("letter-a", "shift-enter", "paste-lf", "mouse-focus-refresh") if case_id in selected_cases): - planned.add((host, path, mode, phase, width, height, case_id)) + 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") @@ -378,7 +386,9 @@ def summarize(document): 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 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"} @@ -386,6 +396,7 @@ def summarize(document): 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 = [ @@ -409,18 +420,44 @@ def qualification_matrix(result): ("Runtime mode transitions", {"mode-transitions"}, None), ] - def cell(case_ids, width, path, modes): + 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 (width is None or row.get("width") == width)] + 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 required_channels for case_id in case_ids} - if required_channels and {(row.get("host"), row.get("case")) for row in matched} != expected_pairs: + 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" @@ -434,7 +471,7 @@ def case_passed(case_id, channel=None): 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 required_channels 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"}: @@ -449,27 +486,42 @@ def case_passed(case_id, channel=None): 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" - table.append((name, cell(case_ids, width, herdr_path, herdr_modes), - cell(case_ids, width, "direct", {"legacy"}), cell(case_ids, width, "direct", {"kitty"}))) + 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_label(result): +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")} + 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 "Win32 (Herdr)*" if runs and runs <= proven else "Herdr default (UNKNOWN)*" + 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", herdr_protocol_label(result), "Plain VT", "Kitty"), *qualification_matrix(result)] - widths = [max(len(str(row[column])) for row in table) for column in range(4)] + 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])) @@ -477,8 +529,11 @@ def print_qualification_matrix(result): for row in table[1:]: print(line(row)) print("* Herdr protocol label comes from runtime evidence; UNKNOWN is never treated as Win32.") - 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.") + 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(): @@ -500,6 +555,8 @@ def main(): 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"]: @@ -510,6 +567,9 @@ def main(): 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 From c411883ec639c9893ed9c33021c890485e91727b Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Thu, 24 Sep 2026 20:59:48 +0300 Subject: [PATCH 59/99] feat!: replace the custom graphics api with optimized native kitty rendering (#4561) * feat!: replace the custom graphics api with optimized native kitty rendering * fix: enable safe kitty optimizations automatically --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- docs/next/api/herdr-api.schema.json | 254 --- .../src/content/docs/configuration.mdx | 16 +- .../src/content/docs/ja/socket-api.mdx | 17 +- .../website/src/content/docs/socket-api.mdx | 67 +- .../src/content/docs/zh-cn/socket-api.mdx | 17 +- .../website/src/data/config-reference.json | 2 +- src/api/mod.rs | 8 - src/api/schema.rs | 21 - src/api/schema/panes.rs | 86 - src/api/schema/response.rs | 27 - src/api/server.rs | 220 +-- src/api/server/pane_graphics_stream.rs | 1114 ------------- src/app/api.rs | 29 - src/app/api/pane_graphics.rs | 1284 --------------- src/app/mod.rs | 7 - src/app/pane_graphics.rs | 350 ---- src/client/catalog_reload.rs | 2 +- src/client/endpoint/supervisor.rs | 1 + src/client/frame_output.rs | 79 +- src/client/frame_output_tests.rs | 192 +++ src/client/handshake.rs | 43 +- src/client/image_files.rs | 138 ++ src/client/mod.rs | 265 +++- src/client/shell/composition.rs | 12 +- src/client/shell/graphics.rs | 34 +- src/client/shell/state.rs | 2 +- src/client/shell/tests/copy.rs | 2 +- src/client/shell/tests/graphics.rs | 148 +- src/client/shell/tests/input.rs | 14 +- src/client/shell/tests/text_editing.rs | 2 +- src/client/shell_runtime.rs | 14 +- src/client/state.rs | 619 +++++++- src/client/terminal_sessions.rs | 1 + src/client/tests/mod.rs | 15 + src/ghostty/bindings.rs | 96 +- src/ghostty/mod.rs | 241 ++- src/ghostty/native_source.rs | 165 ++ src/ghostty/native_source_tests.rs | 146 ++ src/ghostty/png_forward_tests.rs | 232 +++ src/kitty_graphics.rs | 688 ++------ src/kitty_graphics/output.rs | 94 ++ src/kitty_graphics/surface.rs | 790 +++++++-- src/pane.rs | 7 + src/pane/cursor.rs | 79 +- src/pane/osc.rs | 97 +- src/pane/xtgettcap.rs | 96 +- src/pane_graphics_files.rs | 559 ++++++- src/platform/linux.rs | 3 + src/platform/linux/native_image_sources.rs | 96 ++ src/platform/mod.rs | 15 + src/platform/unix_image_files.rs | 602 +++++++ src/protocol/wire.rs | 125 ++ src/server/client_shell.rs | 23 +- src/server/client_shell_graphics.rs | 6 +- src/server/client_transport.rs | 18 + src/server/headless.rs | 233 +-- src/server/headless/endpoint_requests.rs | 1 - src/server/headless/native_graphics.rs | 599 +++++++ src/server/headless/pane_graphics.rs | 513 ------ src/server/headless/render.rs | 169 +- src/server/headless/retained_surface.rs | 51 +- src/server/headless/surface_interest.rs | 2 +- src/server/headless/tests/mod.rs | 24 +- src/server/headless/tests/native_graphics.rs | 1411 +++++++++++++++++ src/server/headless/tests/pane_graphics.rs | 1133 ------------- src/server/headless/tests/pane_move.rs | 1 - src/server/render_stream.rs | 91 +- src/terminal/runtime.rs | 5 + vendor/libghostty-vt.patches.md | 58 + .../include/ghostty/vt/kitty_graphics.h | 20 +- .../include/ghostty/vt/terminal.h | 41 + .../src/terminal/c/kitty_graphics.zig | 32 +- .../libghostty-vt/src/terminal/c/terminal.zig | 27 + .../src/terminal/kitty/graphics_exec.zig | 103 +- .../src/terminal/kitty/graphics_image.zig | 531 ++++++- .../src/terminal/kitty/graphics_storage.zig | 11 +- .../0007-experimental-png-retention.patch | 1055 ++++++++++++ 77 files changed, 9152 insertions(+), 6239 deletions(-) delete mode 100644 src/api/server/pane_graphics_stream.rs delete mode 100644 src/app/api/pane_graphics.rs delete mode 100644 src/app/pane_graphics.rs create mode 100644 src/client/frame_output_tests.rs create mode 100644 src/client/image_files.rs create mode 100644 src/ghostty/native_source.rs create mode 100644 src/ghostty/native_source_tests.rs create mode 100644 src/ghostty/png_forward_tests.rs create mode 100644 src/kitty_graphics/output.rs create mode 100644 src/platform/linux/native_image_sources.rs create mode 100644 src/platform/unix_image_files.rs create mode 100644 src/server/headless/native_graphics.rs delete mode 100644 src/server/headless/pane_graphics.rs create mode 100644 src/server/headless/tests/native_graphics.rs create mode 100644 vendor/patches/libghostty-vt/0007-experimental-png-retention.patch diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index b4648b03..3151bff3 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -2598,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": { @@ -6013,54 +5909,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": { @@ -10027,108 +9875,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, diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 3dae7168..0d68a0f4 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -512,9 +512,19 @@ Search the [Config reference](/docs/config-reference/) for scrollback limits, ne ## Kitty graphics -Herdr renders pane images by default in compatible outer terminals. Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. +Applications and plugins integrate through the standard Kitty graphics protocol emitted in pane terminal output. Herdr renders these images by default in compatible outer terminals. The external socket pane-overlay API has been removed; there is no replacement socket image API. -To disable graphics rendering and the pane graphics API: +Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. + +Herdr automatically chooses eligible file transports, with no application changes or environment switches. On local Unix Ghostty clients, it can send image data through temporary files instead of encoding it into terminal output. A small file-consumption probe checks support first. Other terminals, SSH-launched clients, and nested multiplexers retain inline output for this last-hop optimization. + +For eligible local clients, native RGBA images travel from server to client as paths to Herdr-owned snapshots rather than pixel bytes. Unsupported clients and failed transfers fall back to inline delivery. Remote server paths are never forwarded to the local terminal. Herdr keeps image placement, clipping, and visibility under its control. + +On Linux, whole-file, uncompressed RGBA uploads can use copy-on-write snapshots before Herdr reads their pixels. This requires filesystem support for cloning into Herdr's private `/var/tmp` store. Unsupported filesystems, temporary-file uploads, shared memory, and other formats use the normal loader. Snapshots are limited to 16 MiB each and 64 MiB total; animation and unsupported clients read their pixels when needed. Herdr never relies on a mutable producer path after accepting an image. + +PNG uploads retain full decoding and validation. Herdr does not defer corrupt-image validation to the outer terminal. + +To disable Kitty graphics rendering: ```toml [terminal] @@ -523,7 +533,7 @@ kitty_graphics = false The legacy `experimental.kitty_graphics` setting remains accepted for existing configurations. `terminal.kitty_graphics` takes precedence when both are set. -Changing this setting requires restarting the affected Herdr server or reattaching the client. In remote sessions, the server's setting controls pane graphics parsing and API availability, while the local client's setting controls output to the outer terminal. +Changing this setting requires restarting the affected Herdr server or reattaching the client. In remote sessions, the server's setting controls Kitty graphics parsing, while the local client's setting controls output to the outer terminal. ## Agent session restore diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index 09532d5e..35a03625 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -99,7 +99,7 @@ herdr pane read w1:p2 --source recent --lines 50 | ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | | Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | | タブ | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | -| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | +| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | | ポップアップ | `popup.close` | | レイアウト | `layout.export`、`layout.apply`、`layout.set_split_ratio` | | エージェント | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` | @@ -147,20 +147,9 @@ CLI の `herdr api snapshot` は、クライアントやエージェントが簡 ### ペイングラフィックス -ペイングラフィックスを使うと、プラグインがペイン上に画像データを配置できます。デフォルトで利用でき、サーバーで `[terminal].kitty_graphics = false` が設定されている場合は、すべてのペイングラフィックスメソッドが `feature_disabled` を返します。この設定の変更にはサーバーの再起動が必要です。`pane.graphics.info` はグラフィックスレイヤーを作成せず、接続中クライアントのセルの幅と高さをピクセル単位で返します。`pane.graphics.set` は `data_base64` の `png`、`rgb`、`rgba` データを受け取り、`pane.graphics.clear` はレイヤーを削除します。 +アプリケーションとプラグインは、ペインのターミナル出力を通じて標準の Kitty グラフィックスプロトコルを送信してください。Herdr が対応する外側ターミナルで画像の配置、クリッピング、表示状態を管理します。 -```json -{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} -{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} -{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} -``` - -フレームを繰り返し送る場合は、`pane.graphics.stream` で専用ソケットを開きます。Herdr が `ok` を返した後、フレームごとに 1 つの JSON ヘッダーと正確に `data_length` バイトの生データを送ります。ソケットが閉じるまでストリームがそのペインのグラフィックスレイヤーを所有し、同時に set、clear、stream を要求すると `stream_conflict` を返します。 - -```json -{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}} -{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} -``` +外部ソケットのペイン画像オーバーレイ API は削除されました。`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream` は利用できず、代替のソケット画像 API もありません。旧サーバーとの内部通信プロトコルの互換性は、これらの公開メソッドを復活させるものではありません。 `pane.layout` は、`workspace_id`、`tab_id`、`zoomed`、外側の `area`、`focused_pane_id`、ペインの矩形、分割の矩形/比率を含むタブレイアウトのスナップショットを返します。`pane.neighbor` と `pane.edges` は同じレイアウトスナップショットを含むので、クライアントは非公開のレイアウト状態なしに次の判断を下せます。 diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index e6ae6a57..4572b5ab 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -103,7 +103,7 @@ Raw socket method names use dot notation: | Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.move_block`, `workspace.report_metadata`, `workspace.close` | | Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | | Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` | -| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.graphics.info`, `pane.graphics.set`, `pane.graphics.clear`, `pane.graphics.stream`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | +| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | | Popup | `popup.close` | | Layout | `layout.export`, `layout.apply`, `layout.set_split_ratio` | | Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send_keys`, `agent.prompt`, `agent.wait`, `agent.rename`, `agent.focus`, `agent.start`, `agent.view.set`, `agent.view.clear` | @@ -176,63 +176,14 @@ Clients can treat `offset_from_bottom == 0` as at-bottom state. ### Pane graphics -Pane graphics let a plugin place image data over a pane. They are available by -default; when the server has `[terminal].kitty_graphics = false`, every pane -graphics method returns `feature_disabled`. Changing this setting requires a -server restart. Calling `pane.graphics.info` explicitly -activates capability discovery and returns the attached client's cell size, -file-frame options, pixel-mouse support, the 16-layer limit, and `pane_visible`. -`pane_visible` is true only when the target is in the active workspace and tab -and is not hidden by zoom. Short-lived UI modes do not change it. - -`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` accept an -optional `layer_id` (default `primary`). Set and stream also accept `z_index`; -layers are placed in stable `(z_index, layer_id)` order. Each stream exclusively owns its layer, and -closing it removes that layer. Inline frames accept `png`, `rgb`, `rgba`, or -`bgra`; BGRA is normalized once to owned RGBA. Herdr advances the host cache -one image transaction per render pass, so arbitrary layer sets progress without -an aggregate frame. Client transport keeps each transaction within its 32 MiB -wire limit. - -```json -{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} -{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} -{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} -``` - -For repeated frames, open a dedicated socket with `pane.graphics.stream`. After -Herdr replies with `ok`, send one JSON header and then exactly `data_length` raw -bytes per inline frame. Concurrent operations on that layer return -`stream_conflict`. - -```json -{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1","z_index":0}} -{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} -``` - -When `pane.graphics.info` advertises `file_frame_transport: "direct-kitty"`, an -eligible local Ghostty, kitty, or WezTerm client may submit an immutable private -`rgba` or `bgra` file with `file.path`, `sequence`, and `revision`. Direct Kitty -file transport is reserved for the default `primary` page layer; named secondary -layers use owned inline RGBA. BGRA is always copied, swizzled, and rendered -inline. Herdr replies with a `pane_graphics_frame_ack` only after the terminal -accepts the file, or after a safe owned inline fallback is installed. Confirmed -file-transport failure disables direct files for that client connection without -disabling exact pixel mouse. A timeout or client loss closes the stream without -acknowledging source reuse. Clients that cannot negotiate direct file transport -remain on the owned inline fallback. - -Direct files are always complete canonical `width * height * 4` RGBA frames. -`file_frame_max_bytes` is the limit that remains eligible for owned inline fallback. -Primary-layer RGBA files may use the larger `file_frame_direct_max_bytes` limit when -`file_frame_transport` is available. Frames above the fallback limit are acknowledged -only when the terminal accepts the direct transfer; rejection closes the stream. If a -frame cannot use owned inline fallback while its pane is temporarily hidden or cannot be -placed during a redraw, Herdr uploads the image without displaying it and replays its -placement when the pane becomes visible again. -`file_frame_damage: true` means Herdr accepts optional damage metadata for -producer-side canonical-ring efficiency; it still copies or presents the full file. -Resize and full redraw replay placements without retransmitting pixels. +Applications and plugins should emit the standard Kitty graphics protocol through +their pane's terminal output. Herdr manages image placement, clipping, and +visibility in compatible outer terminals. + +The external socket pane-overlay API has been removed: `pane.graphics.info`, +`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` are no +longer available. There is no replacement socket image API. Internal wire +compatibility with older servers does not restore these public methods. `pane.layout` returns the tab layout snapshot with `workspace_id`, `tab_id`, `zoomed`, outer `area`, `focused_pane_id`, pane rects, and split rects/ratios. diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index 3aae7131..f947fc43 100644 --- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx @@ -99,7 +99,7 @@ herdr pane read w1:p2 --source recent --lines 50 | 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | | Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | | 标签页 | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | -| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | +| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | | 弹窗 | `popup.close` | | 布局 | `layout.export`、`layout.apply`、`layout.set_split_ratio` | | 智能体 | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` | @@ -147,20 +147,9 @@ CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSO ### 窗格图形 -窗格图形允许插件在窗格上放置图像数据。该功能默认可用;当服务器设置 `[terminal].kitty_graphics = false` 时,所有窗格图形方法都返回 `feature_disabled`。更改此设置需要重启服务器。调用 `pane.graphics.info` 会返回所连接客户端的单元格像素宽度和高度,但不会创建图形层。`pane.graphics.set` 接受 `data_base64` 中的 `png`、`rgb` 或 `rgba` 数据,`pane.graphics.clear` 删除该图层。 +应用和插件应通过窗格的终端输出发送标准 Kitty 图形协议。Herdr 在兼容的外层终端中管理图像的位置、裁剪和可见性。 -```json -{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} -{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} -{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} -``` - -要重复发送帧,用 `pane.graphics.stream` 打开专用 socket。Herdr 回复 `ok` 后,每一帧发送一个 JSON 头,再发送恰好 `data_length` 字节的原始数据。socket 关闭前,该流拥有对应窗格的图形层;并发 set、clear 或 stream 请求返回 `stream_conflict`。 - -```json -{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}} -{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} -``` +外部 socket 窗格图像叠加 API 已移除:`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear` 和 `pane.graphics.stream` 不再可用,也没有替代的 socket 图像 API。与旧服务器的内部传输协议兼容性不会恢复这些公开方法。 `pane.layout` 返回标签页布局快照,包含 `workspace_id`、`tab_id`、`zoomed`、外层 `area`、`focused_pane_id`、窗格矩形和分割矩形/比例。`pane.neighbor` 和 `pane.edges` 也包含同一份布局快照,让客户端不需要私有布局状态就能做出下一步决策。 diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 76318e3a..299c2ced 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -439,7 +439,7 @@ "key": "terminal.kitty_graphics", "type": "boolean", "default": "true", - "description": "Render pane images in Kitty graphics-compatible outer terminals. Set false to disable graphics rendering and the pane graphics API. Restart the server or reattach the client after changing it. In remote sessions, server configuration controls parsing and API availability while local client configuration controls outer-terminal output." + "description": "Render pane images in Kitty graphics-compatible outer terminals. Applications use the standard Kitty graphics protocol through pane terminal output; the external socket pane-overlay API is no longer available. Set false to disable graphics rendering. Restart the server or reattach the client after changing it. In remote sessions, server configuration controls Kitty graphics parsing while local client configuration controls outer-terminal output." } ] }, diff --git a/src/api/mod.rs b/src/api/mod.rs index bd2983e4..86f90da8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -64,13 +64,6 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | 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(_) @@ -91,7 +84,6 @@ pub struct ApiRequestMessage { pub request: Request, pub respond_to: std::sync::mpsc::Sender<String>, pub response_write_complete: Option<std::sync::mpsc::Receiver<()>>, - pub stream_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, } pub type ApiRequestSender = mpsc::UnboundedSender<ApiRequestMessage>; diff --git a/src/api/schema.rs b/src/api/schema.rs index d7e1a950..439ed331 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -201,27 +201,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/panes.rs b/src/api/schema/panes.rs index 67d5185e..dfef31f8 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<String>, - #[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<Vec<u8>>, - #[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<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PaneGraphicsDirectParams { - pub pane_id: String, - pub layer_id: Option<String>, - 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<String>, - #[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, diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 5cc66bdc..e92e4952 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -181,33 +181,6 @@ pub enum ResponseResult { #[serde(default, skip_serializing_if = "Option::is_none")] current_global: Option<u64>, }, - 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<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - file_frame_formats: Vec<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - file_frame_max_bytes: Option<usize>, - #[serde(default, skip_serializing_if = "Option::is_none")] - file_frame_direct_max_bytes: Option<usize>, - /// 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<String>, - }, AgentExplain { explain: serde_json::Value, }, diff --git a/src/api/server.rs b/src/api/server.rs index edb778f7..30d92be4 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -22,8 +22,6 @@ use crate::ipc::{ socket_file_identity, LocalStream, LocalStreamRead, SocketFileIdentity, }; -mod pane_graphics_stream; - #[cfg(test)] mod subscription_socket_tests; @@ -162,6 +160,30 @@ fn start_server_inner( }) } +fn retired_pane_graphics_method_error(line: &str, id: &str) -> Option<ErrorResponse> { + #[derive(serde::Deserialize)] + struct RequestMethod { + method: String, + } + + let envelope = serde_json::from_str::<RequestMethod>(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!( @@ -233,16 +255,15 @@ fn handle_connection_with_stop( } else { String::new() }; - write_json_line_allow_disconnect( - &mut stream, - &ErrorResponse { + 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(()); } }; @@ -296,22 +317,6 @@ fn handle_connection_with_stop( } Ok(()) } - 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()) - } - } - result - } Method::EventsSubscribe(params) => { let result = stream_subscriptions( stream, @@ -478,7 +483,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 { @@ -561,14 +566,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", @@ -928,7 +925,7 @@ pub(super) fn dispatch_to_app_with_timeout( api_tx: &ApiRequestSender, timeout: Option<Duration>, ) -> 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( @@ -941,55 +938,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<AtomicBool>, -) -> 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<AtomicBool>, -) -> 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<Duration>, response_write_complete: Option<std::sync::mpsc::Receiver<()>>, - stream_active: Option<Arc<AtomicBool>>, 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", @@ -1019,9 +985,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()); @@ -1299,6 +1262,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::<ApiRequestMessage>(); + 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::<ApiRequestMessage>(); + 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::<ApiRequestMessage>(); + 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(); @@ -1758,39 +1808,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::<Request>(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<usize>, - #[serde(default)] - file: Option<FrameFile>, - #[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<AtomicBool>, -) -> 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<AtomicBool>, - 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<AtomicBool>, - 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<AtomicBool>, - stream_active: &Arc<AtomicBool>, - 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::<FrameHeader>(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<AtomicBool>, - stream_active: &Arc<AtomicBool>, - max_bytes: usize, - idle_timeout: Duration, - total_timeout: Duration, -) -> std::io::Result<Option<String>> { - 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<AtomicBool>, - stream_active: &Arc<AtomicBool>, - idle_timeout: Duration, - total_timeout: Duration, -) -> std::io::Result<Option<Vec<u8>>> { - 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<usize> { - 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<Instant>, total_deadline: Option<Instant>) { - 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<T>( - stream: &mut LocalStream, - read: impl FnOnce(&mut LocalStream, ReadWait) -> std::io::Result<Option<T>>, -) -> std::io::Result<Option<T>> { - 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<T>( - result: std::io::Result<Option<T>>, - reset: impl FnOnce() -> std::io::Result<()>, -) -> std::io::Result<Option<T>> { - 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<Instant>, - total_deadline: Option<Instant>, - 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<Instant>, - total_deadline: Option<Instant>, - 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::<ApiRequestMessage>(); - 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::<ApiRequestMessage>(); - 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::<ApiRequestMessage>(); - 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::<ApiRequestMessage>(); - 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::<ApiRequestMessage>(); - 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::<ApiRequestMessage>(); - 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/app/api.rs b/src/app/api.rs index c56a07a1..62c8a6b5 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; @@ -1152,34 +1151,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<std::sync::atomic::AtomicBool>, - response: &str, - ) { - if serde_json::from_str::<crate::api::schema::SuccessResponse>(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<PaneGraphicsKey, &'static str> { - 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<Option<usize>, ()> { - 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::<serde_json::Value>(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::<SuccessResponse>(&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::<SuccessResponse>(&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::<ErrorResponse>(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::<SuccessResponse>( - &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::<SuccessResponse>(&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::<SuccessResponse>( - &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::<SuccessResponse>(&app.handle_pane_graphics_set( - "set-static".into(), - frame(pane_id.clone(), Some("set"), "", 4), - )) - .is_ok() - ); - assert!( - serde_json::from_str::<SuccessResponse>(&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::<SuccessResponse>(&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::<SuccessResponse>( - &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::<SuccessResponse>(&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::<SuccessResponse>(&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::<SuccessResponse>( - &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::<SuccessResponse>(&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/mod.rs b/src/app/mod.rs index 03e89026..e358a562 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<crate::pane_graphics_files::FileStore>, - pub(crate) direct_graphics_available: bool, pub(crate) pixel_mouse_available: bool, pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry, pub event_tx: mpsc::Sender<AppEvent>, @@ -575,9 +571,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, 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<u8>), - 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<String>, -} - -#[derive(Debug)] -pub(crate) struct Slot { - pub(crate) host_image_id: u32, - pub(crate) layer: Option<Layer>, - pub(crate) stream_owner: Option<String>, - pub(crate) stream_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, - pub(crate) direct_gate: Option<DirectGate>, -} - -impl Slot { - #[cfg(test)] - pub(crate) fn test(host_image_id: u32, layer: Option<Layer>) -> 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<u64> { - 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<Key, Slot>, - 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<u32> { - 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<std::sync::atomic::AtomicBool>, - ) { - 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<u8>, - 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<u64> { - 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/client/catalog_reload.rs b/src/client/catalog_reload.rs index a20b74dc..89715f7d 100644 --- a/src/client/catalog_reload.rs +++ b/src/client/catalog_reload.rs @@ -47,7 +47,7 @@ pub(super) fn apply_profiles( endpoints.disconnect(&endpoint_id); let cancelled = commands.disconnect(&endpoint_id); #[cfg(unix)] - state.retire_endpoint_graphics(&endpoint_id); + state.forget_endpoint_graphics(&endpoint_id); if let Some(shell) = state.shell.as_mut() { for request_id in cancelled { shell.cancel_endpoint_request(&request_id); diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs index ff86ac77..5b4e7ad1 100644 --- a/src/client/endpoint/supervisor.rs +++ b/src/client/endpoint/supervisor.rs @@ -303,6 +303,7 @@ fn connect_once( options.endpoint_keybindings, options.mouse_capture, false, + matches!(target, ConnectTarget::Local(_)), ) .map_err(handshake_error)?; if handshake.encoding != RenderEncoding::SemanticFrame { diff --git a/src/client/frame_output.rs b/src/client/frame_output.rs index 5d12c78c..d9e03f14 100644 --- a/src/client/frame_output.rs +++ b/src/client/frame_output.rs @@ -1,11 +1,86 @@ use std::collections::HashSet; -use std::io; +use std::io::{self, Write as _}; use std::sync::{Mutex, OnceLock}; -use crate::protocol::render_ansi; +use crate::kitty_graphics::{GraphicsOperation, GraphicsOutput}; +use crate::protocol::{render_ansi, FrameData}; +use base64::Engine as _; + +/// Local output only; raw uploads are never added to a published frame codec. +#[derive(Debug)] +pub(crate) struct ComposedFrame { + pub(crate) frame: FrameData, + pub(crate) graphics: GraphicsOutput, +} + +impl From<FrameData> for ComposedFrame { + fn from(mut frame: FrameData) -> Self { + let graphics = GraphicsOutput::from_bytes(std::mem::take(&mut frame.graphics)); + Self { frame, graphics } + } +} + +impl std::ops::Deref for ComposedFrame { + type Target = FrameData; + + fn deref(&self) -> &Self::Target { + &self.frame + } +} static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); +pub(super) fn write_composed_frame( + mut writer: impl io::Write, + encoded: &[u8], + graphics: &GraphicsOutput, + files: &mut super::image_files::FileTransport, +) -> io::Result<()> { + if graphics.is_empty() { + return writer.write_all(encoded); + } + let mut writer = io::BufWriter::with_capacity(64 * 1024, writer); + let insertion = render_ansi::final_sync_output_end(encoded).unwrap_or(encoded.len()); + writer.write_all(&encoded[..insertion])?; + writer.write_all(b"\x1b7")?; + for operation in &graphics.operations { + match operation { + GraphicsOperation::Bytes(bytes) => { + record_received_kitty_graphics(bytes); + writer.write_all(bytes)?; + } + GraphicsOperation::Upload { control, data } => { + let file_eligible = control + .split(',') + .any(|part| matches!(part, "f=24" | "f=32" | "f=100")); + if file_eligible { + if let Some(path) = files + .probe() + .and_then(|path| path.to_str().map(str::to_owned)) + { + let path = + base64::engine::general_purpose::STANDARD.encode(path.as_bytes()); + write!(writer, "\x1b_Ga=q,t=t,f=32,s=1,v=1,i=1,q=2;{path}\x1b\\")?; + } + } + let path = file_eligible.then(|| files.prepare(data)).flatten(); + let header = format!("\x1b_G{control};\x1b\\"); + record_received_kitty_graphics(header.as_bytes()); + if let Some(path) = path.as_ref().and_then(|path| path.to_str()) { + let control = control.replace(",t=d,", ",t=t,"); + let path = base64::engine::general_purpose::STANDARD.encode(path.as_bytes()); + write!(writer, "\x1b_G{control};{path}\x1b\\")?; + } else { + crate::kitty_graphics::write_kitty_data(&mut writer, control, data)?; + } + } + } + } + writer.write_all(b"\x1b8")?; + writer.write_all(&encoded[insertion..])?; + io::Write::flush(&mut writer) +} + pub(super) fn write_encoded_frame_with_graphics( mut writer: impl io::Write, encoded: &[u8], diff --git a/src/client/frame_output_tests.rs b/src/client/frame_output_tests.rs new file mode 100644 index 00000000..19c3717b --- /dev/null +++ b/src/client/frame_output_tests.rs @@ -0,0 +1,192 @@ +use super::frame_output::*; +use super::image_files::FileTransport; +use crate::kitty_graphics::{GraphicsOperation, GraphicsOutput}; +use std::sync::Arc; + +fn output(format: u32) -> GraphicsOutput { + GraphicsOutput { + operations: vec![ + GraphicsOperation::Bytes(b"before".to_vec()), + GraphicsOperation::Upload { + control: format!("a=t,t=d,f={format},s=1,v=1,i=42,q=2"), + data: Arc::from([1, 2, 3, 4]), + }, + GraphicsOperation::Bytes(b"after".to_vec()), + ], + } +} + +#[test] +fn deferred_inline_output_matches_existing_frame_envelope() { + let graphics = output(32); + let encoded = b"\x1b[?2026htext\x1b[?2026l"; + let mut expected = Vec::new(); + write_encoded_frame_with_graphics( + &mut expected, + encoded, + &graphics.clone().into_inline_bytes(), + ) + .unwrap(); + let mut actual = Vec::new(); + write_composed_frame( + &mut actual, + encoded, + &graphics, + &mut FileTransport::default(), + ) + .unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn native_swap_cleanup_and_placements_share_the_frame_sync_envelope() { + let graphics = GraphicsOutput::from_bytes( + concat!( + "\x1b_Ga=d,d=I,i=41,q=2;\x1b\\", + "\x1b_Ga=p,i=42,p=1,q=2;\x1b\\", + "\x1b_Ga=p,i=42,p=2,q=2;\x1b\\", + ) + .as_bytes() + .to_vec(), + ); + let mut actual = Vec::new(); + write_composed_frame( + &mut actual, + b"\x1b[?2026hframe\x1b[?2026l", + &graphics, + &mut FileTransport::default(), + ) + .unwrap(); + let text = std::str::from_utf8(&actual).unwrap(); + let sync_begin = text.find("\x1b[?2026h").unwrap(); + let sync_end = text.rfind("\x1b[?2026l").unwrap(); + let deletion = text.find("a=d,d=I,i=41").unwrap(); + let placements = text + .match_indices("a=p,i=42") + .map(|(at, _)| at) + .collect::<Vec<_>>(); + assert!(deletion > sync_begin && deletion < sync_end); + assert!(placements.len() >= 2); + assert!(placements + .iter() + .all(|placement| *placement > sync_begin && *placement < sync_end)); +} + +#[test] +fn deferred_output_propagates_write_failure() { + struct Broken; + impl std::io::Write for Broken { + fn write(&mut self, _: &[u8]) -> std::io::Result<usize> { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + assert_eq!( + write_composed_frame(Broken, b"text", &output(32), &mut FileTransport::default()) + .unwrap_err() + .kind(), + std::io::ErrorKind::BrokenPipe + ); +} + +#[cfg(unix)] +mod local_files { + use super::*; + use base64::Engine as _; + use std::path::PathBuf; + struct Scratch(PathBuf); + impl Scratch { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = PathBuf::from(format!( + "/var/tmp/herdr-output-test-{}-{id}", + std::process::id() + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn command_path(bytes: &[u8], header: &str) -> PathBuf { + let text = std::str::from_utf8(bytes).unwrap(); + let command = text + .split("\x1b_G") + .find(|part| part.starts_with(header)) + .unwrap(); + let payload = command + .split_once(';') + .unwrap() + .1 + .split("\x1b\\") + .next() + .unwrap(); + PathBuf::from( + String::from_utf8( + base64::engine::general_purpose::STANDARD + .decode(payload) + .unwrap(), + ) + .unwrap(), + ) + } + #[test] + fn files_begin_only_after_probe_consumption_and_keep_order() { + for format in [24, 32, 100] { + let root = Scratch::new(); + let mut files = FileTransport::for_test(root.0.clone()); + let graphics = output(format); + let mut first = Vec::new(); + write_composed_frame(&mut first, b"text", &graphics, &mut files).unwrap(); + let first_text = std::str::from_utf8(&first).unwrap(); + assert!(first_text.contains("a=q,t=t")); + assert!(first_text.contains("a=t,t=d")); + let probe = command_path(&first, "a=q,"); + assert_eq!(std::fs::read(&probe).unwrap().len(), 4); + let mut waiting = Vec::new(); + write_composed_frame(&mut waiting, b"text", &graphics, &mut files).unwrap(); + assert!(!std::str::from_utf8(&waiting).unwrap().contains("a=q,")); + assert!(std::str::from_utf8(&waiting).unwrap().contains("a=t,t=d")); + std::fs::remove_file(&probe).unwrap(); + let mut ready = Vec::new(); + write_composed_frame(&mut ready, b"text", &graphics, &mut files).unwrap(); + let text = std::str::from_utf8(&ready).unwrap(); + assert!(text.contains(&format!("before\x1b_Ga=t,t=t,f={format}"))); + assert!(text.contains("\x1b\\after")); + assert!(!text.contains("AQIDBA==")); + let image = command_path(&ready, "a=t,"); + assert_eq!(std::fs::read(&image).unwrap(), [1, 2, 3, 4]); + drop(files); + assert!(!image.exists()); + } + } + #[test] + fn unsupported_format_and_file_creation_failure_remain_inline() { + let root = Scratch::new(); + let mut files = FileTransport::for_test(root.0.clone()); + let mut unsupported = Vec::new(); + write_composed_frame(&mut unsupported, b"", &output(999), &mut files).unwrap(); + assert!(std::str::from_utf8(&unsupported) + .unwrap() + .contains("a=t,t=d,f=999")); + assert_eq!(std::fs::read_dir(&root.0).unwrap().count(), 0); + let mut broken = FileTransport::for_test(root.0.join("absent")); + let mut actual = Vec::new(); + write_composed_frame(&mut actual, b"", &output(32), &mut broken).unwrap(); + let mut expected = Vec::new(); + write_composed_frame( + &mut expected, + b"", + &output(32), + &mut FileTransport::default(), + ) + .unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/src/client/handshake.rs b/src/client/handshake.rs index 69e2f52f..f25cc1b0 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -69,6 +69,17 @@ pub(super) fn direct_graphics_profile_values( supported && !blocked_transport && terminals } +/// Server-owned files require a shared filesystem; the host terminal profile alone +/// cannot establish that for a saved SSH endpoint. +fn direct_graphics_capability( + local_transport: bool, + exact_cell_size: bool, + cell_size: (u32, u32), + profile_allowed: bool, +) -> bool { + local_transport && exact_cell_size && cell_size.0 > 0 && cell_size.1 > 0 && profile_allowed +} + #[cfg(unix)] fn direct_graphics_profile_allowed() -> bool { let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); @@ -138,6 +149,7 @@ pub(crate) fn probe_endpoint_negotiation( false, false, false, + false, // This probe uses an SSH bridge, not a shared filesystem. ) .map_err(io::Error::other)?; Ok(super::endpoint::EndpointNegotiation::new( @@ -150,7 +162,8 @@ pub(crate) fn probe_endpoint_negotiation( /// /// Direct terminal clients retain the same-install private protocol. Client-owned /// shells use the stable endpoint generation and negotiate whole codecs without -/// comparing Herdr build versions. +/// comparing Herdr build versions. `local_transport` means the endpoint shares the +/// client's filesystem, not merely that its bridge exposes a local socket. pub(super) fn do_handshake( stream: &mut LocalStream, cols: u16, @@ -162,6 +175,7 @@ pub(super) fn do_handshake( endpoint_keybindings: bool, mouse_capture: bool, surface_active: bool, + local_transport: bool, ) -> Result<HandshakeResult, ClientError> { stream .set_nonblocking(false) @@ -175,10 +189,12 @@ pub(super) fn do_handshake( cell_height_px, surface_size, pixel_mouse: exact_cell_size && cfg!(unix), - direct_graphics: exact_cell_size - && cell_width_px > 0 - && cell_height_px > 0 - && direct_graphics_profile_allowed(), + direct_graphics: direct_graphics_capability( + local_transport, + exact_cell_size, + (cell_width_px, cell_height_px), + direct_graphics_profile_allowed(), + ), endpoint_keybindings, mouse_capture, surface_active, @@ -295,3 +311,20 @@ pub(super) fn do_handshake( ))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_graphics_requires_local_transport_even_on_supported_host_terminal() { + let supported = direct_graphics_profile_values("ghostty", "", false, false, true); + assert!(supported); + assert!(direct_graphics_capability(true, true, (8, 16), supported)); + assert!(!direct_graphics_capability(false, true, (8, 16), supported)); + assert!(!direct_graphics_capability(true, false, (8, 16), supported)); + assert!(!direct_graphics_capability(true, true, (0, 16), supported)); + assert!(!direct_graphics_capability(true, true, (8, 0), supported)); + assert!(!direct_graphics_capability(true, true, (8, 16), false)); + } +} diff --git a/src/client/image_files.rs b/src/client/image_files.rs new file mode 100644 index 00000000..cc854c85 --- /dev/null +++ b/src/client/image_files.rs @@ -0,0 +1,138 @@ +//! Client-owned Kitty `t=t` files, prepared only at the final output boundary. +//! +//! A returned path is published: it must stay readable until the terminal unlinks +//! it, or the client shuts down. Never use `cleanup` to cancel a frame. This ledger +//! does not encode pixels or accept paths from a server. A silent raw-file query +//! gates conversion for the local Ghostty allowlist; an explicit kill switch +//! always wins. Local writes alone do not prove support. Non-consumption disables +//! new files for this client, without deleting files the terminal may still open. + +use std::path::PathBuf; + +#[derive(Default)] +pub(crate) struct FileTransport { + #[cfg(unix)] + inner: Option<crate::platform::unix_image_files::Ledger>, +} + +impl FileTransport { + #[cfg(all(test, unix))] + pub(crate) fn for_test(root: PathBuf) -> Self { + Self { + inner: Some(crate::platform::unix_image_files::Ledger::for_test(root)), + } + } + + pub(crate) fn from_environment() -> Self { + Self { + #[cfg(unix)] + inner: environment_allows().then(crate::platform::unix_image_files::Ledger::new), + } + } + + /// Once per client, return a private 1x1 RGBA query payload. The output + /// boundary must emit this as `a=q,t=t,f=32,s=1,v=1,q=2` before inline image + /// output. No real upload is converted until Ghostty consumes this file. + pub(crate) fn probe(&mut self) -> Option<PathBuf> { + #[cfg(unix)] + { + self.inner.as_mut()?.probe() + } + #[cfg(not(unix))] + { + None + } + } + + /// Returns a complete, closed file containing exactly `data`, or requests + /// inline fallback. The caller must preserve output ordering and encode the + /// returned local path as Kitty's base64 filename payload with `t=t`. + pub(crate) fn prepare(&mut self, data: &[u8]) -> Option<PathBuf> { + #[cfg(unix)] + { + self.inner.as_mut()?.prepare(data) + } + #[cfg(not(unix))] + { + let _ = data; + None + } + } +} + +#[cfg(unix)] +use std::ffi::OsString; +#[cfg(unix)] +use std::io::IsTerminal; + +#[cfg(unix)] +fn environment_allows() -> bool { + allowed( + std::io::stdin().is_terminal(), + std::io::stdout().is_terminal(), + |key| std::env::var_os(key), + ) +} + +#[cfg(unix)] +fn allowed(stdin_tty: bool, stdout_tty: bool, env: impl Fn(&str) -> Option<OsString>) -> bool { + stdin_tty + && stdout_tty + && env("TERM_PROGRAM").as_deref() == Some(std::ffi::OsStr::new("ghostty")) + && [ + "SSH_CONNECTION", + "SSH_TTY", + "SSH_CLIENT", + "TMUX", + "STY", + "HERDR_PANE_ID", + "HERDR_PANE_RUNTIME_ID", + ] + .iter() + .all(|key| env(key).is_none()) + // Named-session selection (HERDR_SESSION) and HERDR_REMOTE_KEYBINDINGS + // deliberately do not affect client locality. +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + #[test] + fn conservative_gate_without_process_environment_mutation() { + let base = |key: &str| match key { + "TERM_PROGRAM" => Some(OsString::from("ghostty")), + "HERDR_SESSION" => Some(OsString::from("named-session")), + "HERDR_REMOTE_KEYBINDINGS" => Some(OsString::from("1")), + _ => None, + }; + assert!(allowed(true, true, base)); + assert!(!allowed(false, true, base)); + assert!(!allowed(true, false, base)); + for key in [ + "SSH_CONNECTION", + "SSH_TTY", + "SSH_CLIENT", + "TMUX", + "STY", + "HERDR_PANE_ID", + "HERDR_PANE_RUNTIME_ID", + ] { + assert!(!allowed(true, true, |name| if name == key { + Some(OsString::new()) + } else { + base(name) + })); + } + assert!(!allowed(true, true, |key| if key == "TERM_PROGRAM" { + Some(OsString::from("kitty")) + } else { + base(key) + })); + } + #[test] + fn default_is_disabled() { + let mut transport = FileTransport::default(); + assert!(transport.probe().is_none()); + assert!(transport.prepare(b"inline").is_none()); + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 9ad51a36..1a0f8dd5 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -24,7 +24,10 @@ mod endpoint_commands; mod errors; mod events; mod frame_output; +#[cfg(test)] +mod frame_output_tests; mod handshake; +mod image_files; mod input; mod loop_config; mod notifications; @@ -48,6 +51,8 @@ use events::ClientLoopEvent; use loop_config::ClientLoopConfig; use shell_runtime::*; use state::ClientState; +#[cfg(unix)] +use state::RetiredDirectGraphicsMatch; use transport::*; #[cfg(test)] @@ -135,7 +140,7 @@ use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -use crate::protocol::{self, ClientMessage, FrameData, ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; +use crate::protocol::{self, ClientMessage, ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; #[cfg(test)] use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; @@ -244,6 +249,7 @@ fn run_client_with_mode( endpoint_keybindings, loop_config.mouse_capture_active, true, + !is_remote_client_process(), ) .map_err(|error| io::Error::other(error.to_string()))?; if federated @@ -364,6 +370,31 @@ fn run_client_with_mode( Ok(()) } +// This guards server-supplied paths only. Client-owned temporary files generated +// from received graphics bytes remain usable for remote endpoints. +fn server_graphics_files_allowed( + endpoint_id: &endpoint::ClientEndpointId, + remote_client_process: bool, +) -> bool { + endpoint_id.is_local() && !remote_client_process +} + +#[cfg(unix)] +fn graphics_owner_is_active( + state: &ClientState, + endpoints: &endpoint::EndpointRegistry, + owner: &endpoint::ClientEndpointId, +) -> bool { + endpoints.active_id() == owner + && endpoints + .connection(owner) + .is_some_and(|connection| connection.surface_active) + && state + .shell + .as_ref() + .is_some_and(|shell| shell.endpoint_is_active(owner)) +} + /// The main client event loop. /// /// Uses a threaded architecture: @@ -392,6 +423,7 @@ async fn run_client_loop( let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), + image_files: image_files::FileTransport::from_environment(), mouse_capture_active: config.mouse_capture_active, endpoint_mouse_capture_requested: false, endpoint_sgr_pixels_requested: false, @@ -410,7 +442,10 @@ async fn run_client_loop( #[cfg(unix)] direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())), #[cfg(unix)] - retired_direct_graphics: None, + retired_direct_graphics: HashMap::new(), + #[cfg(unix)] + disabled_native_graphics: Default::default(), + pending_native_cleanup: Vec::new(), #[cfg(unix)] pending_surface_graphics: HashMap::new(), attach_escape, @@ -690,10 +725,10 @@ async fn run_client_loop( let frozen = state.presentation_frozen; state.presentation_frozen = false; state.present_graphics(&cleanup); + state.presentation_frozen = frozen; if let Some(frame) = frame { - state.present_frame(frame); + state.present_frozen_chrome(frame); } - state.presentation_frozen = frozen; } } } @@ -937,42 +972,70 @@ async fn run_client_loop( let pending_key = state .pending_surface_graphics .keys() - .find(|(_, transfer_id, image_id)| { + .find(|(_, _, transfer_id, image_id)| { *transfer_id == response.transfer_id && *image_id == response.image_id }) .cloned(); - let owner = pending_key - .as_ref() - .map(|(endpoint_id, _, _)| endpoint_id.clone()); - let composed = pending_key - .and_then(|key| { - state - .pending_surface_graphics - .remove(&key) - .map(|asset| (key, asset)) - }) - .filter(|_| response.success) - .and_then(|((endpoint_id, _, _), asset)| { - if write_stream.active_id() != &endpoint_id { + let pending = pending_key.and_then(|key| { + state + .pending_surface_graphics + .remove(&key) + .map(|asset| (key, asset)) + }); + let Some(((owner, _, _, _), asset)) = pending else { + // Raw/non-surface transfers retain their existing response handling. + continue; + }; + let eligible = response.success + && graphics_owner_is_active(&state, &write_stream, &owner) + && !state.presentation_frozen + && state.shell.as_ref().is_some_and(|shell| { + shell.accepts_direct_graphics_asset(&asset, response.image_id) + }); + let size = state.reported_size; + let prepared = eligible + .then(|| { + let shell = state.shell.as_mut()?; + let checkpoint = shell.direct_graphics_checkpoint(); + if !shell.trust_direct_graphics_asset(&asset, response.image_id) { + shell.restore_direct_graphics_checkpoint(checkpoint); return None; } - let shell = state.shell.as_mut()?; - shell - .trust_direct_graphics_asset(&asset, response.image_id) - .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) - .flatten() - }); + match shell.compose(size.0, size.1) { + Some(frame) => Some((frame, checkpoint)), + None => { + shell.restore_direct_graphics_checkpoint(checkpoint); + None + } + } + }) + .flatten(); + let accepted = if let Some((frame, checkpoint)) = prepared { + if state.try_present_frame(frame) { + true + } else { + state + .shell + .as_mut() + .expect("prepared direct graphics requires a shell") + .restore_direct_graphics_checkpoint(checkpoint); + false + } + } else { + false + }; + if !accepted { + // The upload may have reached the terminal even when its response, owner, or + // composed swap cannot be accepted. Never leave that unowned image resident. + // Restoring the checkpoint also restores old-bank stale-image bookkeeping. + state.queue_native_image_cleanup(response.image_id); + } let message = ClientMessage::GraphicsTransmissionResult { transfer_id: response.transfer_id, image_id: response.image_id, - success: response.success, + success: accepted, }; - if let Some(owner) = owner { - write_stream.send_to(&owner, &message); - } - if let Some(frame) = composed { - state.present_frame(frame); - } + write_stream.send_to(&owner, &message); } #[cfg(unix)] ClientLoopEvent::PixelMouse(data, geometry) => { @@ -1228,6 +1291,8 @@ async fn run_client_loop( shell.compose(state.reported_size.0, state.reported_size.1) }); let reader_quit = writer.stop_handle(); + #[cfg(unix)] + state.start_endpoint_graphics_generation(&endpoint_id, generation); write_stream.insert( endpoint_id.clone(), writer, @@ -1287,6 +1352,30 @@ async fn run_client_loop( continue; } write_stream.received(&endpoint_id, generation, now); + // Retirements belong to the exact live connection, even after deactivation. + // They must not be dropped with frozen/inactive presentation effects. + if matches!( + message.as_ref(), + ServerMessage::GraphicsTransmissionRetired { .. } + ) { + #[cfg(unix)] + if let ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + } = message.as_ref() + { + let owner_active = + graphics_owner_is_active(&state, &write_stream, &endpoint_id); + state.receive_graphics_retirement( + &endpoint_id, + generation, + *transfer_id, + *image_id, + owner_active, + ); + } + continue; + } let endpoint_active = write_stream.active_id() == &endpoint_id && write_stream .connection(&endpoint_id) @@ -1463,32 +1552,73 @@ async fn run_client_loop( control, surface_asset, } => { + // Never interpret an SSH server's filesystem path on this host, + // including legacy peers that send files without negotiation. + if !server_graphics_files_allowed(&endpoint_id, is_remote_client_process()) + { + write_stream.send_to( + &endpoint_id, + &ClientMessage::GraphicsTransmissionResult { + transfer_id, + image_id, + success: false, + }, + ); + continue; + } #[cfg(unix)] { - if state.retired_direct_graphics.take() - == Some((endpoint_id.clone(), transfer_id, image_id)) - { + let retirement = state.match_retired_direct_graphics( + &endpoint_id, + generation, + transfer_id, + image_id, + ); + if retirement == RetiredDirectGraphicsMatch::Exact { continue; } let surface_asset_valid = match (state.shell.as_ref(), &surface_asset) { (Some(shell), Some(asset)) => { - crate::kitty_graphics::surface::host_image_id( - shell.graphics_scope(), - asset, - ) == image_id + shell.accepts_direct_graphics_asset(asset, image_id) } (None, None) => true, _ => false, }; - let valid = state.kitty_graphics_enabled + let native = surface_asset.as_ref().is_some_and(|asset| { + matches!( + asset.source, + crate::protocol::SurfaceGraphicsSource::Terminal { .. } + ) + }); + let native_valid = !native + || surface_asset.as_ref().is_some_and(|asset| { + !state.presentation_frozen + && state.disabled_native_graphics.get(&endpoint_id) + != Some(&generation) + && graphics_owner_is_active( + &state, + &write_stream, + &endpoint_id, + ) + && leading.is_empty() + && asset.format + == crate::protocol::SurfaceGraphicsFormat::Rgba + && expected_len == asset.data_len + && control + == format!( + "a=t,f=32,s={},v={},i={image_id},q=0", + asset.image_width, asset.image_height + ) + }); + let valid = retirement != RetiredDirectGraphicsMatch::Saturated + && state.kitty_graphics_enabled + && native_valid && surface_asset_valid && usize::try_from(expected_len).ok().is_some_and(|len| { - crate::pane_graphics_files::validate_direct_source( - std::path::Path::new(&path), - len, - ) - .is_ok() - && direct_graphics::valid_control(&control, image_id, len) + let path = std::path::Path::new(&path); + let valid_source = crate::pane_graphics_files::validate_direct_source(path, len).is_ok() + || (native && crate::pane_graphics_files::validate_native_source(path, len).is_ok()); + valid_source && direct_graphics::valid_control(&control, image_id, len) }) && state .direct_graphics_response @@ -1503,8 +1633,9 @@ async fn run_client_loop( &path, ); let mut stdout = io::stdout(); - let written = stdout - .write_all(&command) + let written = state + .flush_native_cleanup(&mut stdout) + .and_then(|()| stdout.write_all(&command)) .and_then(|()| stdout.flush()) .is_ok(); if written { @@ -1517,7 +1648,7 @@ async fn run_client_loop( if sent { if let Some(asset) = surface_asset { state.pending_surface_graphics.insert( - (endpoint_id.clone(), transfer_id, image_id), + (endpoint_id.clone(), generation, transfer_id, image_id), asset, ); } @@ -1528,12 +1659,11 @@ async fn run_client_loop( transfer_id, image_id, }; - if let Err(err) = write_to_server(&mut write_stream, &started) { - return Err(ClientError::ConnectionLost(err)); - } + write_stream.send_to(&endpoint_id, &started); } else { state.pending_surface_graphics.remove(&( endpoint_id.clone(), + generation, transfer_id, image_id, )); @@ -1549,9 +1679,7 @@ async fn run_client_loop( image_id, success: false, }; - if let Err(err) = write_to_server(&mut write_stream, &result) { - return Err(ClientError::ConnectionLost(err)); - } + write_stream.send_to(&endpoint_id, &result); } } #[cfg(not(unix))] @@ -1565,33 +1693,8 @@ async fn run_client_loop( surface_asset, ); } - ServerMessage::GraphicsTransmissionRetired { - transfer_id, - image_id, - } => { - #[cfg(unix)] - { - state.retired_direct_graphics = - Some((endpoint_id.clone(), transfer_id, image_id)); - state.pending_surface_graphics.remove(&( - endpoint_id.clone(), - transfer_id, - image_id, - )); - let cleanup = state.shell.as_mut().map_or_else(Vec::new, |shell| { - shell.retire_direct_graphics_image(image_id); - shell - .compose(state.reported_size.0, state.reported_size.1) - .map(|frame| frame.graphics) - .unwrap_or_else(|| shell.take_pending_graphics_cleanup()) - }); - state.present_graphics(&cleanup); - if let Ok(mut matcher) = state.direct_graphics_response.lock() { - matcher.retire(transfer_id); - } - } - #[cfg(not(unix))] - let _ = (transfer_id, image_id); + ServerMessage::GraphicsTransmissionRetired { .. } => { + unreachable!("retirements are handled before presentation gating") } ServerMessage::ServerShutdown { reason } => { if !federated && endpoint_id.is_local() { diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 9b710dd5..b7ab4a96 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -147,7 +147,11 @@ impl ClientShellState { FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, None, &[]) } - pub(crate) fn compose(&mut self, cols: u16, rows: u16) -> Option<FrameData> { + pub(crate) fn compose( + &mut self, + cols: u16, + rows: u16, + ) -> Option<crate::client::frame_output::ComposedFrame> { self.last_composed_at = Some(std::time::Instant::now()); self.selection_repaint_deadline = None; if self.last_composed_size != Some((cols, rows)) && self.mode == ClientShellMode::Navigate { @@ -167,7 +171,7 @@ impl ClientShellState { && self.navigation_target_valid(&pending.target) }); if self.snapshot.is_none() || self.pane_surface.is_none() { - return Some(self.compose_unavailable(cols, rows)); + return Some(self.compose_unavailable(cols, rows).into()); } let snapshot = self.snapshot.as_deref()?; // Do not compose a retained surface while waiting for its matching snapshot or @@ -716,8 +720,8 @@ impl ClientShellState { self.hits.pane_splits.clear(); self.hits.popup = None; } - self.compose_graphics(&mut frame, layout, &occlusion); - Some(frame) + let graphics = self.compose_graphics(layout, &occlusion); + Some(crate::client::frame_output::ComposedFrame { frame, graphics }) } } diff --git a/src/client/shell/graphics.rs b/src/client/shell/graphics.rs index a7ae81e3..35e61dbb 100644 --- a/src/client/shell/graphics.rs +++ b/src/client/shell/graphics.rs @@ -1,11 +1,36 @@ use super::*; +#[cfg(unix)] +pub(crate) struct ClientGraphicsCheckpoint(crate::kitty_graphics::surface::ClientState); + impl ClientShellState { - #[cfg(unix)] + #[cfg(all(test, unix))] pub(crate) fn graphics_scope(&self) -> &str { self.graphics.scope() } + #[cfg(unix)] + pub(crate) fn accepts_direct_graphics_asset( + &self, + key: &crate::protocol::SurfaceGraphicsAssetKey, + image_id: u32, + ) -> bool { + self.graphics.accepts_direct_asset(key, image_id) + } + + #[cfg(unix)] + pub(crate) fn direct_graphics_checkpoint(&self) -> ClientGraphicsCheckpoint { + ClientGraphicsCheckpoint(self.graphics.clone()) + } + + #[cfg(unix)] + pub(crate) fn restore_direct_graphics_checkpoint( + &mut self, + checkpoint: ClientGraphicsCheckpoint, + ) { + self.graphics = checkpoint.0; + } + #[cfg(unix)] pub(crate) fn trust_direct_graphics_asset( &mut self, @@ -33,10 +58,9 @@ impl ClientShellState { pub(super) fn compose_graphics( &mut self, - frame: &mut FrameData, layout: ClientShellLayout, occlusion: &crate::kitty_graphics::surface::Occlusion, - ) { + ) -> crate::kitty_graphics::GraphicsOutput { let visibility = if self.endpoint_error.is_some() { crate::kitty_graphics::surface::Visibility::Hidden } else if self.hits.popup.is_some() { @@ -49,12 +73,12 @@ impl ClientShellState { .popup .as_ref() .map(|popup| (popup.inner_rect.x, popup.inner_rect.y)); - frame.graphics = self.graphics.encode( + self.graphics.encode_output( visibility, (layout.pane_surface.x, layout.pane_surface.y), popup_origin, self.graphics_cell_size, occlusion, - ); + ) } } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 0d6937e0..c170c9b0 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -1015,7 +1015,7 @@ impl ClientShellState { pane_surface_generation: None, pane_surface: None, pending_pane_surface: None, - graphics: crate::kitty_graphics::surface::ClientState::default(), + graphics: crate::kitty_graphics::surface::ClientState::new(), graphics_cell_size: crate::kitty_graphics::HostCellSize { width_px: 1, height_px: 1, diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 112417fc..67e36f23 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1563,7 +1563,7 @@ fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() { navigator.query = "界".repeat(100).as_str().into(); let frame = state.compose(width, height).expect("navigator frame"); let popup = state.hits.navigator_popup; - let cursor = frame.cursor.expect("search cursor"); + let cursor = frame.cursor.as_ref().expect("search cursor"); assert!(super::super::contains(popup, (cursor.x, cursor.y))); assert!(state.hits.navigator_rows.is_empty()); assert!(popup.right() <= width && popup.bottom() <= height); diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs index 240e031c..e54301a4 100644 --- a/src/client/shell/tests/graphics.rs +++ b/src/client/shell/tests/graphics.rs @@ -63,6 +63,48 @@ fn add_main_image( surface.graphics.placements.push(placement); } +#[cfg(unix)] +#[test] +fn failed_direct_ack_composition_restores_graphics_for_inline_retry() { + use crate::kitty_graphics::surface::host_image_id; + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane = surface(); + let (mut asset, mut placement) = image( + SurfaceGraphicsTarget::Pane { + pane_id: "pane_1".into(), + }, + 0, + 0, + 71, + ); + asset.key.source = SurfaceGraphicsSource::PaneLayer { + pane_id: "pane_1".into(), + layer_id: "direct-rollback".into(), + }; + placement.asset = asset.key.clone(); + let key = asset.key.clone(); + pane.graphics.assets.push(asset); + pane.graphics.placements.push(placement); + state.set_pane_surface(pane); + + // Model an ACK arriving while projection pairing makes composition unavailable. + state.pending_pane_surface = Some(surface()); + let image_id = host_image_id(state.graphics_scope(), &key); + let graphics_before = format!("{:?}", state.graphics); + let checkpoint = state.direct_graphics_checkpoint(); + assert!(state.trust_direct_graphics_asset(&key, image_id)); + assert!(state.compose(106, 20).is_none()); + state.restore_direct_graphics_checkpoint(checkpoint); + assert_eq!(format!("{:?}", state.graphics), graphics_before); + + // Once pairing is available, fallback still owns the asset and uploads it normally. + state.pending_pane_surface = None; + let retry = state.compose(106, 20).expect("fallback frame"); + assert!(String::from_utf8_lossy(&retry.graphics.into_inline_bytes()).contains("a=t")); +} + fn is_placed(bytes: &[u8], point: (u16, u16)) -> bool { String::from_utf8_lossy(bytes).contains(&format!("\x1b[{};{}H", point.1 + 1, point.0 + 1)) } @@ -85,11 +127,11 @@ fn assert_graphics_cover(state: &mut ClientShellState, covered: Rect, cols: u16, state.set_pane_surface(surface); let frame = state.compose(cols, rows).unwrap(); assert!( - is_placed(&frame.graphics, outside), + is_placed(&frame.graphics.clone().into_inline_bytes(), outside), "outside={outside:?} cover={covered:?}" ); assert!( - !is_placed(&frame.graphics, inside), + !is_placed(&frame.graphics.clone().into_inline_bytes(), inside), "inside={inside:?} cover={covered:?}" ); } @@ -327,18 +369,38 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { state.compose(106, 40).unwrap(); state.overlay = Some(overlay); let frame = state.compose(106, 40).unwrap(); - assert!(is_placed(&frame.graphics, outside), "{:?}", state.overlay); + assert!( + is_placed(&frame.graphics.clone().into_inline_bytes(), outside), + "{:?}", + state.overlay + ); if let Some(border) = border { - assert!(!is_placed(&frame.graphics, border), "{:?}", state.overlay); - assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i")); + assert!( + !is_placed(&frame.graphics.clone().into_inline_bytes(), border), + "{:?}", + state.overlay + ); + assert!( + String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()) + .contains("a=d,d=i") + ); } state.overlay = None; let restored = state.compose(106, 40).unwrap(); - assert!(is_placed(&restored.graphics, outside)); + assert!(is_placed( + &restored.graphics.clone().into_inline_bytes(), + outside + )); if let Some(border) = border { - assert!(is_placed(&restored.graphics, border)); + assert!(is_placed( + &restored.graphics.clone().into_inline_bytes(), + border + )); } - assert!(!String::from_utf8_lossy(&restored.graphics).contains("a=t")); + assert!( + !String::from_utf8_lossy(&restored.graphics.clone().into_inline_bytes()) + .contains("a=t") + ); } } @@ -377,17 +439,26 @@ fn selection_copy_cursor_and_search_hide_only_the_highlighted_images() { }); let frame = state.compose(106, 20).unwrap(); for point in &points[..3] { - assert!(!is_placed(&frame.graphics, *point), "{point:?}"); + assert!( + !is_placed(&frame.graphics.clone().into_inline_bytes(), *point), + "{point:?}" + ); } - assert!(is_placed(&frame.graphics, points[3])); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + points[3] + )); state.mode = ClientShellMode::Terminal; state.copy_mode = None; state.selection = None; let frame = state.compose(106, 20).unwrap(); for point in points { - assert!(is_placed(&frame.graphics, point)); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + point + )); } - assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); + assert!(!String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=t")); } #[test] @@ -400,15 +471,26 @@ fn mobile_switcher_still_hides_the_entire_underlying_surface() { add_main_image(&mut surface, layout, point, 1); state.set_pane_surface(surface); let frame = state.compose(40, 24).unwrap(); - assert!(is_placed(&frame.graphics, point)); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + point + )); state.mode = ClientShellMode::Navigate; let frame = state.compose(40, 24).unwrap(); - assert!(!is_placed(&frame.graphics, point)); - assert!(String::from_utf8_lossy(&frame.graphics).contains("a=d,d=i")); + assert!(!is_placed( + &frame.graphics.clone().into_inline_bytes(), + point + )); + assert!( + String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=d,d=i") + ); state.mode = ClientShellMode::Terminal; let frame = state.compose(40, 24).unwrap(); - assert!(is_placed(&frame.graphics, point)); - assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + point + )); + assert!(!String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=t")); } #[test] @@ -437,15 +519,33 @@ fn popup_terminal_keeps_own_graphics_and_hides_only_background_overlap() { surface.graphics.placements.push(placement); state.set_pane_surface(surface); let frame = state.compose(106, 20).unwrap(); - assert!(is_placed(&frame.graphics, outside)); - assert!(!is_placed(&frame.graphics, border)); - assert!(is_placed(&frame.graphics, inside)); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + outside + )); + assert!(!is_placed( + &frame.graphics.clone().into_inline_bytes(), + border + )); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + inside + )); state.overlay = Some(ClientShellOverlay::Onboarding); let frame = state.compose(106, 20).unwrap(); - assert!(is_placed(&frame.graphics, outside)); - assert!(!is_placed(&frame.graphics, inside)); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + outside + )); + assert!(!is_placed( + &frame.graphics.clone().into_inline_bytes(), + inside + )); state.overlay = None; let frame = state.compose(106, 20).unwrap(); - assert!(is_placed(&frame.graphics, inside)); - assert!(!String::from_utf8_lossy(&frame.graphics).contains("a=t")); + assert!(is_placed( + &frame.graphics.clone().into_inline_bytes(), + inside + )); + assert!(!String::from_utf8_lossy(&frame.graphics.clone().into_inline_bytes()).contains("a=t")); } diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index b55cf9a2..b54548f2 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -216,18 +216,24 @@ fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility( state.set_pane_surface(pane_surface); let visible = state.compose(106, 20).expect("visible graphics frame"); - let visible = String::from_utf8_lossy(&visible.graphics); + let visible = visible.graphics.clone().into_inline_bytes(); + let visible = String::from_utf8_lossy(&visible); assert!(visible.contains("a=t,t=d")); assert!(visible.contains("\u{1b}[2;27H")); state.overlay = Some(ClientShellOverlay::Onboarding); let uncovered = state.compose(106, 20).expect("overlay frame"); - assert!(!String::from_utf8_lossy(&uncovered.graphics).contains("a=d")); - assert!(String::from_utf8_lossy(&uncovered.graphics).contains("a=p")); + assert!( + !String::from_utf8_lossy(&uncovered.graphics.clone().into_inline_bytes()).contains("a=d") + ); + assert!( + String::from_utf8_lossy(&uncovered.graphics.clone().into_inline_bytes()).contains("a=p") + ); state.overlay = None; let restored = state.compose(106, 20).expect("restored graphics frame"); - let restored = String::from_utf8_lossy(&restored.graphics); + let restored = restored.graphics.clone().into_inline_bytes(); + let restored = String::from_utf8_lossy(&restored); assert!(restored.contains("a=p")); assert!(!restored.contains("a=t,t=d")); } diff --git a/src/client/shell/tests/text_editing.rs b/src/client/shell/tests/text_editing.rs index b63ce9f6..118a606e 100644 --- a/src/client/shell/tests/text_editing.rs +++ b/src/client/shell/tests/text_editing.rs @@ -384,7 +384,7 @@ fn every_field_renders_long_unicode_across_resize_without_mutation() { for (width, height) in [(120, 40), (60, 20), (12, 6), (1, 1), (120, 40)] { let before = editor(&mut state).clone(); if let Some(frame) = state.compose(width, height) { - if let Some(cursor) = frame.cursor.filter(|cursor| cursor.visible) { + if let Some(cursor) = frame.cursor.as_ref().filter(|cursor| cursor.visible) { assert!(cursor.x < width && cursor.y < height, "field {field}"); } } diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs index 3843654f..d0eafdf6 100644 --- a/src/client/shell_runtime.rs +++ b/src/client/shell_runtime.rs @@ -385,14 +385,6 @@ pub(super) fn complete_endpoint_activation( completion, endpoint::ActivationCompletion::AwaitingPresentationSync { .. } ) { - #[cfg(unix)] - if let endpoint::ActivationCompletion::AwaitingPresentationSync { previous, endpoint } = - &completion - { - if previous != endpoint { - state.retire_endpoint_graphics(previous); - } - } // The coherent target frame can replace the frozen source now, but the registry keeps // pane input disabled until a second projection epoch has replayed host modes/effects. state.unfreeze_presentation(); @@ -511,7 +503,7 @@ pub(super) fn handle_endpoint_disconnect( ) -> bool { supervisors.disconnected(endpoint_id, generation, now); #[cfg(unix)] - state.retire_endpoint_graphics(endpoint_id); + state.retire_endpoint_graphics(endpoint_id, generation); if pending_activation .as_ref() .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) @@ -572,7 +564,7 @@ pub(super) fn handle_endpoint_attention( now, ); #[cfg(unix)] - state.retire_endpoint_graphics(endpoint_id); + state.retire_endpoint_graphics(endpoint_id, generation); if pending_activation .as_ref() .is_some_and(|pending| pending.involves_endpoint(endpoint_id)) @@ -681,7 +673,7 @@ pub(super) fn install_client_shell_snapshot( pub(super) fn finish_client_shell_input( state: &mut ClientState, outcome: shell::ClientShellInput, - frame: Option<FrameData>, + frame: Option<super::frame_output::ComposedFrame>, endpoints: &mut endpoint::EndpointRegistry, pending_activation: &mut Option<endpoint::PendingEndpointActivation>, endpoint_commands: &mut endpoint_commands::EndpointCommands, diff --git a/src/client/state.rs b/src/client/state.rs index 83b07429..e6b8abf3 100644 --- a/src/client/state.rs +++ b/src/client/state.rs @@ -1,9 +1,39 @@ use super::*; +#[cfg(unix)] +const MAX_RETIRED_DIRECT_GRAPHICS: usize = 64; + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RetiredDirectGraphicsMatch { + None, + Exact, + Saturated, +} + +#[cfg(unix)] +pub(super) struct RetiredDirectGraphics { + generation: u64, + transfers: Vec<(u64, u32)>, + saturated: bool, +} + +#[cfg(unix)] +impl RetiredDirectGraphics { + fn new(generation: u64) -> Self { + Self { + generation, + transfers: Vec::new(), + saturated: false, + } + } +} + /// State tracking for the thin client. pub(super) struct ClientState { /// Stateful semantic-frame encoder used when the server sends FrameData. pub(super) blit_encoder: render_ansi::BlitEncoder, + pub(super) image_files: image_files::FileTransport, pub(super) mouse_capture_active: bool, pub(super) endpoint_mouse_capture_requested: bool, pub(super) endpoint_sgr_pixels_requested: bool, @@ -24,10 +54,15 @@ pub(super) struct ClientState { #[cfg(unix)] pub(super) direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>, #[cfg(unix)] - pub(super) retired_direct_graphics: Option<(endpoint::ClientEndpointId, u64, u32)>, + pub(super) retired_direct_graphics: HashMap<endpoint::ClientEndpointId, RetiredDirectGraphics>, #[cfg(unix)] - pub(super) pending_surface_graphics: - HashMap<(endpoint::ClientEndpointId, u64, u32), crate::protocol::SurfaceGraphicsAssetKey>, + pub(super) disabled_native_graphics: HashMap<endpoint::ClientEndpointId, u64>, + pub(super) pending_native_cleanup: Vec<u8>, + #[cfg(unix)] + pub(super) pending_surface_graphics: HashMap< + (endpoint::ClientEndpointId, u64, u64, u32), + crate::protocol::SurfaceGraphicsAssetKey, + >, pub(super) attach_escape: Option<AttachEscapeState>, #[cfg(unix)] pub(super) mouse_scroll_lines: usize, @@ -63,6 +98,7 @@ impl ClientState { pub(super) fn test_new() -> Self { Self { blit_encoder: render_ansi::BlitEncoder::new(), + image_files: image_files::FileTransport::default(), mouse_capture_active: false, endpoint_mouse_capture_requested: false, endpoint_sgr_pixels_requested: false, @@ -81,7 +117,10 @@ impl ClientState { #[cfg(unix)] direct_graphics_response: Default::default(), #[cfg(unix)] - retired_direct_graphics: None, + retired_direct_graphics: HashMap::new(), + #[cfg(unix)] + disabled_native_graphics: Default::default(), + pending_native_cleanup: Vec::new(), #[cfg(unix)] pending_surface_graphics: HashMap::new(), attach_escape: None, @@ -162,11 +201,109 @@ impl ClientState { /// Present a composed error/chrome frame while retaining the handoff input freeze. The pane /// cells are still the last coherent surface; only client chrome (including the error) moves. - pub(super) fn present_frozen_chrome(&mut self, frame_data: FrameData) { + pub(super) fn present_frozen_chrome( + &mut self, + frame_data: impl Into<frame_output::ComposedFrame>, + ) { let frozen = self.presentation_frozen; + // Chrome can repaint the frozen source, but cannot retire its staged images. + let deferred_cleanup = frozen.then(|| std::mem::take(&mut self.pending_native_cleanup)); self.presentation_frozen = false; self.present_frame(frame_data); self.presentation_frozen = frozen; + if let Some(cleanup) = deferred_cleanup { + self.pending_native_cleanup = cleanup; + } + } + + #[cfg(unix)] + pub(super) fn queue_native_image_cleanup(&mut self, image_id: u32) { + crate::kitty_graphics::encode_delete_image(&mut self.pending_native_cleanup, image_id); + } + + /// Retirement is lifecycle bookkeeping, not a presentation effect. The caller has + /// already checked the endpoint generation, even for inactive/frozen owners. + #[cfg(unix)] + pub(super) fn receive_graphics_retirement( + &mut self, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + transfer_id: u64, + image_id: u32, + owner_active: bool, + ) { + if transfer_id & crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT != 0 { + self.disabled_native_graphics + .insert(endpoint_id.clone(), generation); + } + self.record_retired_direct_graphics(endpoint_id.clone(), generation, transfer_id, image_id); + let upload_pending = self + .pending_surface_graphics + .remove(&(endpoint_id.clone(), generation, transfer_id, image_id)) + .is_some(); + // Retirement cleanup is terminal-owned. A native retirement owns the + // ID only while its exact upload is pending: collision rejection and a + // late post-ACK retirement must not delete the currently visible bank. + // API/non-native retirement retains its prior ownership behavior. + let owns_image = self.queue_retired_graphics_cleanup( + transfer_id, + image_id, + upload_pending, + owner_active, + ); + let frame = if owns_image && owner_active { + let frozen = self.presentation_frozen; + let size = self.reported_size; + self.shell.as_mut().and_then(|shell| { + shell.retire_direct_graphics_image(image_id); + (!frozen).then(|| shell.compose(size.0, size.1)).flatten() + }) + } else { + None + }; + // A retirement repaint is a normal synchronized frame. In particular, + // do not extract its graphics into a graphics-only swap. + if let Some(frame) = frame { + self.present_frame(frame); + } + if let Ok(mut matcher) = self.direct_graphics_response.lock() { + matcher.retire(transfer_id); + } + } + + /// Returns whether a retirement owns an image that may be removed from the terminal/cache. + /// Native rejections can retire a proposed colliding ID without ever uploading it; only an + /// exact still-pending native upload gives that retirement ownership of the image. + #[cfg(unix)] + pub(super) fn queue_retired_graphics_cleanup( + &mut self, + transfer_id: u64, + image_id: u32, + upload_pending: bool, + owner_active: bool, + ) -> bool { + let native = transfer_id & crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT != 0; + if native && !upload_pending { + return false; + } + if native || !owner_active { + self.queue_native_image_cleanup(image_id); + } + true + } + + #[cfg(unix)] + pub(super) fn flush_native_cleanup( + &mut self, + writer: &mut impl std::io::Write, + ) -> io::Result<()> { + if self.presentation_frozen || self.pending_native_cleanup.is_empty() { + return Ok(()); + } + writer.write_all(&self.pending_native_cleanup)?; + writer.flush()?; + self.pending_native_cleanup.clear(); + Ok(()) } pub(super) fn present_graphics(&mut self, graphics: &[u8]) { @@ -182,7 +319,10 @@ impl ClientState { &mut self, patch: shell::ClientComposedSurfacePatch, ) -> io::Result<bool> { - if self.presentation_frozen || self.repaint_pending { + if self.presentation_frozen + || self.repaint_pending + || (self.kitty_graphics_enabled && !self.pending_native_cleanup.is_empty()) + { crate::render_prof::event("client_surface_patch.fallback.repaint"); return Ok(false); } @@ -222,21 +362,28 @@ impl ClientState { } #[cfg(unix)] - pub(super) fn retire_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) { - let transfer_ids = self + fn retire_pending_endpoint_graphics( + &mut self, + endpoint_id: &endpoint::ClientEndpointId, + generation: Option<u64>, + ) { + let retired = self .pending_surface_graphics .keys() - .filter(|(owner, _, _)| owner == endpoint_id) - .map(|(_, transfer_id, _)| *transfer_id) + .filter(|(owner, pending_generation, _, _)| { + owner == endpoint_id + && generation.is_none_or(|generation| *pending_generation == generation) + }) + .cloned() .collect::<Vec<_>>(); - self.pending_surface_graphics - .retain(|(owner, _, _), _| owner != endpoint_id); - if self - .retired_direct_graphics - .as_ref() - .is_some_and(|(owner, _, _)| owner == endpoint_id) - { - self.retired_direct_graphics = None; + let mut transfer_ids = Vec::with_capacity(retired.len()); + for key in retired { + let (_, _, transfer_id, image_id) = &key; + if transfer_id & crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT != 0 { + self.queue_native_image_cleanup(*image_id); + } + transfer_ids.push(*transfer_id); + self.pending_surface_graphics.remove(&key); } if let Ok(mut matcher) = self.direct_graphics_response.lock() { for transfer_id in transfer_ids { @@ -245,10 +392,162 @@ impl ClientState { } } - pub(super) fn present_frame(&mut self, frame_data: FrameData) { - if self.presentation_frozen { + #[cfg(unix)] + pub(super) fn record_retired_direct_graphics( + &mut self, + endpoint_id: endpoint::ClientEndpointId, + generation: u64, + transfer_id: u64, + image_id: u32, + ) { + let retired = self + .retired_direct_graphics + .entry(endpoint_id) + .or_insert_with(|| RetiredDirectGraphics::new(generation)); + if retired.generation != generation { + *retired = RetiredDirectGraphics::new(generation); + } + if retired.saturated || retired.transfers.contains(&(transfer_id, image_id)) { + return; + } + if retired.transfers.len() == MAX_RETIRED_DIRECT_GRAPHICS { + // Never evict an older tombstone: a delayed file for an evicted tuple could otherwise + // overwrite a visible image. Reject every unrecognized file until generation reset. + retired.saturated = true; return; } + retired.transfers.push((transfer_id, image_id)); + } + + #[cfg(unix)] + pub(super) fn match_retired_direct_graphics( + &mut self, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + transfer_id: u64, + image_id: u32, + ) -> RetiredDirectGraphicsMatch { + let Some(retired) = self.retired_direct_graphics.get_mut(endpoint_id) else { + return RetiredDirectGraphicsMatch::None; + }; + if retired.generation != generation { + return RetiredDirectGraphicsMatch::None; + } + if let Some(index) = retired + .transfers + .iter() + .position(|transfer| *transfer == (transfer_id, image_id)) + { + retired.transfers.swap_remove(index); + if retired.transfers.is_empty() && !retired.saturated { + self.retired_direct_graphics.remove(endpoint_id); + } + return RetiredDirectGraphicsMatch::Exact; + } + if retired.saturated { + RetiredDirectGraphicsMatch::Saturated + } else { + RetiredDirectGraphicsMatch::None + } + } + + /// Forget graphics guards only for the connection generation which actually disconnected. + /// A delayed disconnect from an older generation must not clear its replacement's guards. + #[cfg(unix)] + pub(super) fn retire_endpoint_graphics( + &mut self, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + ) { + self.retire_pending_endpoint_graphics(endpoint_id, Some(generation)); + if self.disabled_native_graphics.get(endpoint_id) == Some(&generation) { + self.disabled_native_graphics.remove(endpoint_id); + } + if self + .retired_direct_graphics + .get(endpoint_id) + .is_some_and(|retired| retired.generation == generation) + { + self.retired_direct_graphics.remove(endpoint_id); + } + } + + /// Reset bounded per-endpoint state when a replacement connection becomes authoritative. + #[cfg(unix)] + pub(super) fn start_endpoint_graphics_generation( + &mut self, + endpoint_id: &endpoint::ClientEndpointId, + generation: u64, + ) { + self.retire_pending_endpoint_graphics(endpoint_id, None); + if self.disabled_native_graphics.get(endpoint_id) != Some(&generation) { + self.disabled_native_graphics.remove(endpoint_id); + } + if self + .retired_direct_graphics + .get(endpoint_id) + .is_some_and(|retired| retired.generation != generation) + { + self.retired_direct_graphics.remove(endpoint_id); + } + } + + #[cfg(unix)] + pub(super) fn forget_endpoint_graphics(&mut self, endpoint_id: &endpoint::ClientEndpointId) { + self.retire_pending_endpoint_graphics(endpoint_id, None); + self.disabled_native_graphics.remove(endpoint_id); + self.retired_direct_graphics.remove(endpoint_id); + } + + pub(super) fn present_frame(&mut self, frame_data: impl Into<frame_output::ComposedFrame>) { + let _ = self.try_present_frame(frame_data); + } + + fn write_composed_output( + &mut self, + writer: &mut impl io::Write, + encoded: &[u8], + mut graphics: crate::kitty_graphics::GraphicsOutput, + ) -> io::Result<()> { + if self.kitty_graphics_enabled { + if !self.pending_native_cleanup.is_empty() { + graphics.operations.insert( + 0, + crate::kitty_graphics::GraphicsOperation::Bytes( + self.pending_native_cleanup.clone(), + ), + ); + } + frame_output::write_composed_frame( + writer.by_ref(), + encoded, + &graphics, + &mut self.image_files, + )?; + } else { + writer.write_all(encoded)?; + } + writer.flush()?; + if self.kitty_graphics_enabled { + self.pending_native_cleanup.clear(); + } + Ok(()) + } + + /// Presents and commits a frame only after all terminal output has been written successfully. + /// Callers which acknowledge presentation-sensitive work use the return value rather than + /// treating composition as presentation. + pub(super) fn try_present_frame( + &mut self, + frame_data: impl Into<frame_output::ComposedFrame>, + ) -> bool { + if self.presentation_frozen { + return false; + } + let frame_output::ComposedFrame { + frame: frame_data, + graphics, + } = frame_data.into(); let frame_data = if self.draw_host_cursor { render_ansi::frame_with_drawn_cursor(frame_data) } else { @@ -261,14 +560,280 @@ impl ClientState { self.blit_encoder.encode(&frame_data, self.repaint_pending) }; let mut stdout = io::stdout(); - let graphics = if self.kitty_graphics_enabled { - frame_data.graphics.as_slice() - } else { - &[] - }; - let _ = write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); - let _ = stdout.flush(); + if let Err(error) = self.write_composed_output(&mut stdout, &encoded.bytes, graphics) { + tracing::warn!(%error, "failed to present client frame"); + self.repaint_pending = true; + return false; + } self.blit_encoder.commit(frame_data, encoded); self.repaint_pending = false; + true + } +} + +#[cfg(all(test, unix))] +mod native_cleanup_tests { + use super::*; + + #[test] + fn retired_graphics_tombstones_survive_handoff_and_only_exact_files_consume_them() { + let mut state = ClientState::test_new(); + let endpoint = endpoint::ClientEndpointId::Local; + state.record_retired_direct_graphics(endpoint.clone(), 7, 11, 1234); + state.record_retired_direct_graphics(endpoint.clone(), 7, 12, 5678); + state.disabled_native_graphics.insert(endpoint.clone(), 7); + + // Switching away and back does not reset guards, and an unrelated queued file cannot + // consume either of two sequential API retirement tombstones. + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 7, 13, 9999), + RetiredDirectGraphicsMatch::None + ); + assert_eq!( + state + .retired_direct_graphics + .get(&endpoint) + .unwrap() + .transfers + .len(), + 2 + ); + assert_eq!(state.disabled_native_graphics.get(&endpoint), Some(&7)); + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 7, 11, 1234), + RetiredDirectGraphicsMatch::Exact + ); + assert!(state + .retired_direct_graphics + .get(&endpoint) + .unwrap() + .transfers + .contains(&(12, 5678))); + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 7, 12, 5678), + RetiredDirectGraphicsMatch::Exact + ); + assert!(!state.retired_direct_graphics.contains_key(&endpoint)); + } + + #[test] + fn replacement_generation_resets_bounded_graphics_guards() { + let mut state = ClientState::test_new(); + let endpoint = endpoint::ClientEndpointId::Local; + let native = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 11; + state.record_retired_direct_graphics(endpoint.clone(), 7, native, 1234); + state.disabled_native_graphics.insert(endpoint.clone(), 7); + + state.start_endpoint_graphics_generation(&endpoint, 8); + + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 7, native, 1234), + RetiredDirectGraphicsMatch::None + ); + assert!(!state.disabled_native_graphics.contains_key(&endpoint)); + } + + #[test] + fn retired_graphics_tombstone_overflow_fails_closed_until_generation_reset() { + let mut state = ClientState::test_new(); + let endpoint = endpoint::ClientEndpointId::Local; + for transfer_id in 0..=MAX_RETIRED_DIRECT_GRAPHICS as u64 { + state.record_retired_direct_graphics( + endpoint.clone(), + 7, + transfer_id, + transfer_id as u32, + ); + } + let retired = state.retired_direct_graphics.get(&endpoint).unwrap(); + assert_eq!(retired.transfers.len(), MAX_RETIRED_DIRECT_GRAPHICS); + assert!(retired.saturated); + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 7, 1, 1), + RetiredDirectGraphicsMatch::Exact + ); + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 7, 10_000, 10_000), + RetiredDirectGraphicsMatch::Saturated + ); + assert!( + state + .retired_direct_graphics + .get(&endpoint) + .unwrap() + .saturated + ); + + state.start_endpoint_graphics_generation(&endpoint, 8); + assert!(!state.retired_direct_graphics.contains_key(&endpoint)); + state.record_retired_direct_graphics(endpoint.clone(), 8, 1, 2); + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 8, 9, 9), + RetiredDirectGraphicsMatch::None + ); + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 8, 1, 2), + RetiredDirectGraphicsMatch::Exact + ); + } + + #[test] + fn pending_none_native_retirement_does_not_touch_cleanup_or_shell_cache() { + let mut state = ClientState::test_new(); + state.pending_native_cleanup.extend_from_slice(b"existing"); + let transfer = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 7; + + assert!(!state.queue_retired_graphics_cleanup(transfer, 1234, false, true)); + assert_eq!(state.pending_native_cleanup, b"existing"); + assert!(state + .shell + .as_mut() + .expect("test shell") + .take_pending_graphics_cleanup() + .is_empty()); + } + + #[test] + fn queued_cleanup_is_synchronized_and_respects_graphics_capability() { + let mut state = ClientState::test_new(); + state.kitty_graphics_enabled = true; + state.queue_native_image_cleanup(42); + let encoded = b"\x1b[?2026htext\x1b[?2026l"; + let mut output = Vec::new(); + state + .write_composed_output(&mut output, encoded, Default::default()) + .unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.find("\x1b[?2026h").unwrap() < text.find("a=d,d=I,i=42").unwrap()); + assert!(text.find("a=d,d=I,i=42").unwrap() < text.find("\x1b[?2026l").unwrap()); + assert!(state.pending_native_cleanup.is_empty()); + state.kitty_graphics_enabled = false; + state.queue_native_image_cleanup(43); + let mut output = Vec::new(); + state + .write_composed_output(&mut output, encoded, Default::default()) + .unwrap(); + assert_eq!(output, encoded); + assert!(!state.pending_native_cleanup.is_empty()); + state.kitty_graphics_enabled = true; + let mut no_capacity = &mut [][..]; + assert!(state + .write_composed_output(&mut no_capacity, encoded, Default::default()) + .is_err()); + assert!(!state.pending_native_cleanup.is_empty()); + } + + #[test] + fn active_api_retirement_leaves_cleanup_to_composition() { + let mut state = ClientState::test_new(); + assert!(state.queue_retired_graphics_cleanup(7, 1234, true, true)); + assert!(state.pending_native_cleanup.is_empty()); + assert!(state.queue_retired_graphics_cleanup(8, 5678, true, false)); + assert!(!state.pending_native_cleanup.is_empty()); + } + + #[test] + fn unacknowledged_native_upload_cleanup_survives_frozen_disconnect() { + let mut state = ClientState::test_new(); + let endpoint = endpoint::ClientEndpointId::Local; + let key = crate::protocol::SurfaceGraphicsAssetKey { + source: crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Pane { + pane_id: "p".into(), + }, + image_id: 1, + }, + image_width: 1, + image_height: 1, + format: crate::protocol::SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: 1, + }; + let transfer = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 2; + state + .pending_surface_graphics + .insert((endpoint.clone(), 9, transfer, 1234), key); + state.presentation_frozen = true; + state.retire_endpoint_graphics(&endpoint, 9); + assert!(state.pending_surface_graphics.is_empty()); + let mut output = Vec::new(); + state.flush_native_cleanup(&mut output).unwrap(); + assert!(output.is_empty()); + assert!(!state.pending_native_cleanup.is_empty()); + state.unfreeze_presentation(); + state.flush_native_cleanup(&mut output).unwrap(); + assert!(String::from_utf8(output.clone()) + .unwrap() + .contains("a=d,d=I,i=1234")); + state.flush_native_cleanup(&mut output).unwrap(); + assert_eq!( + String::from_utf8(output).unwrap().matches("a=d,").count(), + 1 + ); + } + + #[test] + fn unacknowledged_native_upload_retirement_survives_frozen_handoff() { + let mut state = ClientState::test_new(); + let endpoint = endpoint::ClientEndpointId::Local; + let key = crate::protocol::SurfaceGraphicsAssetKey { + source: crate::protocol::SurfaceGraphicsSource::Terminal { + target: crate::protocol::SurfaceGraphicsTarget::Pane { + pane_id: "p".into(), + }, + image_id: 1, + }, + image_width: 1, + image_height: 1, + format: crate::protocol::SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: 1, + }; + let transfer = crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT | 2; + state + .pending_surface_graphics + .insert((endpoint.clone(), 9, transfer, 1234), key); + state.presentation_frozen = true; + // The source connection is still live, but no longer owns the active shell. + state.receive_graphics_retirement(&endpoint, 8, transfer, 1234, false); + assert_eq!(state.pending_surface_graphics.len(), 1); + assert!(state.pending_native_cleanup.is_empty()); + state.receive_graphics_retirement(&endpoint, 9, transfer, 1234, false); + assert_eq!( + state.match_retired_direct_graphics(&endpoint, 9, transfer, 1234), + RetiredDirectGraphicsMatch::Exact + ); + assert!(state + .shell + .as_mut() + .unwrap() + .take_pending_graphics_cleanup() + .is_empty()); + assert!(state.pending_surface_graphics.is_empty()); + let mut output = Vec::new(); + state.flush_native_cleanup(&mut output).unwrap(); + assert!(output.is_empty()); + assert!(!state.pending_native_cleanup.is_empty()); + let cleanup = state.pending_native_cleanup.clone(); + let frame = crate::protocol::FrameData::from_ratatui_buffer( + &ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 1, 1)), + None, + ); + state.kitty_graphics_enabled = true; + assert!(!state.try_present_frame(frame.clone())); + assert_eq!(state.pending_native_cleanup, cleanup); + state.present_frozen_chrome(frame); + assert!(state.presentation_frozen); + assert_eq!(state.pending_native_cleanup, cleanup); + state.unfreeze_presentation(); + state.flush_native_cleanup(&mut output).unwrap(); + assert!(String::from_utf8(output.clone()) + .unwrap() + .contains("a=d,d=I,i=1234")); + state.flush_native_cleanup(&mut output).unwrap(); + assert_eq!( + String::from_utf8(output).unwrap().matches("a=d,").count(), + 1 + ); } } diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs index c7328efd..6d3160c8 100644 --- a/src/client/terminal_sessions.rs +++ b/src/client/terminal_sessions.rs @@ -100,6 +100,7 @@ fn connect_terminal_session_stream( false, false, true, + true, ) { Ok(handshake) if handshake.encoding == RenderEncoding::TerminalAnsi => {} Ok(handshake) => { diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 2665e7fd..8519ea07 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -69,6 +69,21 @@ fn direct_graphics_profile_is_narrow_and_transport_safe() { )); } +#[test] +fn server_graphics_files_require_local_filesystem_not_just_local_terminal() { + let local = endpoint::ClientEndpointId::Local; + let ssh = endpoint::ClientEndpointId::Ssh( + endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + ); + // Keep old local peers working without requiring a negotiated capability. + assert!(server_graphics_files_allowed(&local, false)); + // Saved SSH endpoints run in the normal local client process. + assert!(!server_graphics_files_allowed(&ssh, false)); + // A standalone --remote bridge can retain the Local endpoint identity. + assert!(!server_graphics_files_allowed(&local, true)); + assert!(!server_graphics_files_allowed(&ssh, true)); +} + fn restore_env_var(key: &str, value: Option<OsString>) { if let Some(value) = value { std::env::set_var(key, value); diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index ceb2d699..33443aca 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/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::<GhosttyKittyImageSnapshotFileRequest>() - 24usize]; + ["Alignment of GhosttyKittyImageSnapshotFileRequest"] + [::std::mem::align_of::<GhosttyKittyImageSnapshotFileRequest>() - 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<unsafe extern "C" fn(context: *mut ::std::os::raw::c_void)>; +#[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::<GhosttyKittyImageFileBacking>() - 40usize]; + ["Alignment of GhosttyKittyImageFileBacking"] + [::std::mem::align_of::<GhosttyKittyImageFileBacking>() - 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::<Self>::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; diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 82714118..43a70fb1 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -22,7 +22,10 @@ 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; +mod native_source; pub use bindings as ffi; @@ -227,6 +230,7 @@ pub struct KittyImagePlacement { pub data_len: usize, pub data_fingerprint: u64, pub data: Vec<u8>, + pub(crate) source_file: Option<Arc<OwnedExport>>, pub render: KittyPlacementRenderInfo, } @@ -239,8 +243,11 @@ pub struct KittyImageDescriptor { pub format: KittyImageFormat, pub data_len: usize, pub data_fingerprint: u64, + pub source_file: bool, } +type KittyPlacementPayload = (KittyImageDescriptor, Vec<u8>, Option<Arc<OwnedExport>>); + #[derive(Debug, Clone, Copy)] struct KittyImageFingerprintEntry { generation: u64, @@ -690,6 +697,16 @@ fn install_png_decoder_once() { }); } +#[cfg(test)] +thread_local! { + static PNG_DECODE_CALLS: Cell<usize> = 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 +718,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; }; @@ -820,6 +839,7 @@ pub struct Terminal { callback_state: Box<TerminalCallbackState>, kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>, kitty_empty_generation: Cell<Option<u64>>, + kitty_png_forwarding: bool, } impl Terminal { @@ -845,6 +865,7 @@ 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; @@ -1072,6 +1093,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(()) } @@ -1882,6 +1922,65 @@ impl Terminal { } } + // 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<F>( + &self, + image: ffi::GhosttyKittyGraphicsImage, + image_id: u32, + placement_id: u32, + image_width: u32, + image_height: u32, + decoded_format: KittyImageFormat, + needs_data: &mut F, + ) -> Result<KittyPlacementPayload, Error> + 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<F>( &self, graphics: ffi::GhosttyKittyGraphics, @@ -1937,29 +2036,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, @@ -1985,6 +2076,7 @@ impl Terminal { data_len, data_fingerprint, data, + source_file, render: KittyPlacementRenderInfo { pixel_width: raw_info.pixel_width, pixel_height: raw_info.pixel_height, @@ -2081,29 +2173,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, @@ -2116,6 +2200,7 @@ impl Terminal { data_len, data_fingerprint, data, + source_file, render: geometry.render, }); } @@ -2585,6 +2670,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> { @@ -3592,6 +3711,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]; diff --git a/src/ghostty/native_source.rs b/src/ghostty/native_source.rs new file mode 100644 index 00000000..3e4e8a3f --- /dev/null +++ b/src/ghostty/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<FileStore> = 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<Option<Arc<OwnedExport>>, 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::<OwnedExport>(); + // 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::<ffi::GhosttyKittyImageSnapshotFileRequest>() { + 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::<OwnedExport>() }; + 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::<OwnedExport>()) }); + } + }); +} + +#[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::<ffi::GhosttyKittyImageSnapshotFileRequest>(), + 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/src/ghostty/native_source_tests.rs b/src/ghostty/native_source_tests.rs new file mode 100644 index 00000000..bb4ded46 --- /dev/null +++ b/src/ghostty/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/src/ghostty/png_forward_tests.rs b/src/ghostty/png_forward_tests.rs new file mode 100644 index 00000000..6d3904b2 --- /dev/null +++ b/src/ghostty/png_forward_tests.rs @@ -0,0 +1,232 @@ +use super::*; +use base64::Engine as _; + +fn fixture() -> Vec<u8> { + fixture_size(32, 16) +} + +fn fixture_size(width: u32, height: u32) -> Vec<u8> { + 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::<Vec<_>>(); + 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/src/kitty_graphics.rs b/src/kitty_graphics.rs index 2eca21f5..6bca9443 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -9,21 +9,20 @@ use base64::Engine; use ratatui::layout::Rect; use crate::app::state::AppState; -use crate::ghostty::{ - KittyImageDescriptor, KittyImageFormat, KittyImagePlacement, KittyPlacementRenderInfo, -}; -use crate::layout::{PaneId, PaneInfo}; +use crate::ghostty::{KittyImageDescriptor, KittyImageFormat, KittyImagePlacement}; +use crate::layout::PaneId; use crate::terminal::TerminalRuntimeRegistry; +pub(crate) mod output; pub(crate) mod surface; +pub(crate) use output::{GraphicsOperation, GraphicsOutput}; +use std::sync::Arc; + const KITTY_CHUNK_BYTES: usize = 3072; -const MAX_OVERSIZED_SOURCES: usize = 256; pub(crate) const HEADLESS_GRAPHICS_TRANSACTION_BUDGET: usize = crate::protocol::MAX_GRAPHICS_FRAME_SIZE - crate::protocol::MAX_FRAME_SIZE; const HOST_IMAGE_ID_BASE: u32 = 10_000; -#[cfg(test)] -const PANE_GRAPHICS_IMAGE_ID_BIT: u32 = 1 << 31; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct HostCellSize { @@ -39,6 +38,8 @@ impl HostCellSize { #[derive(Debug)] struct HostPlacement { + /// Client-local shared pixels; headless placements retain their inline data. + raw_data: Option<Arc<[u8]>>, pane_id: PaneId, host_image_id: Option<u32>, area: Rect, @@ -54,10 +55,6 @@ enum HostSourceKey { pane_id: PaneId, image_id: u32, }, - PaneLayer { - pane_id: PaneId, - layer_id: String, - }, ClientSurface { scope: String, source: crate::protocol::SurfaceGraphicsSource, @@ -109,7 +106,6 @@ pub(crate) struct HostGraphicsCache { placements: HashMap<(u32, u32), PlacementSignature>, /// Host image currently backing each (pane, source image id) pair. sources: HashMap<HostSourceKey, u32>, - oversized: HashMap<HostSourceKey, ImageSignature>, continuation: Option<(HostSourceKey, u32, usize)>, replay_placements: bool, replayed_placements: HashSet<(u32, u32)>, @@ -125,32 +121,12 @@ pub(crate) fn is_enabled() -> bool { KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire) } +#[cfg(test)] pub(crate) struct EncodedGraphics { pub(crate) bytes: Vec<u8>, pub(crate) incomplete: bool, } -/// Whether appending `additional` bytes to the `current_len` bytes already -/// assembled keeps the transaction inside the caller's budget. Without a -/// budget the incremental path intentionally stays one transaction per call. -fn coalesced_transaction_fits( - current_len: usize, - additional: usize, - transaction_budget: Option<usize>, -) -> bool { - let Some(budget) = transaction_budget else { - return false; - }; - current_len.saturating_add(additional) <= budget -} - -fn image_transaction_fits(placement: &HostPlacement, budget: Option<usize>) -> bool { - let Some(budget) = budget else { - return true; - }; - image_transfer_estimated_size(placement.placement.data_len) <= budget -} - pub(crate) fn image_transfer_estimated_size(data_len: usize) -> usize { let encoded = data_len.div_ceil(3).saturating_mul(4); let command_overhead = data_len.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; @@ -164,23 +140,10 @@ fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { ) } -fn source_order(source: &HostSourceKey) -> (u32, String) { - match source { - HostSourceKey::Terminal { pane_id, .. } => (pane_id.raw(), String::new()), - HostSourceKey::PaneLayer { pane_id, layer_id } => (pane_id.raw(), layer_id.clone()), - HostSourceKey::ClientSurface { scope, source } => { - let mut hasher = DefaultHasher::new(); - scope.hash(&mut hasher); - source.hash(&mut hasher); - (hasher.finish() as u32, format!("{source:?}")) - } - } -} - fn encode_placement_update( cache: &mut HostGraphicsCache, placement: &HostPlacement, -) -> Option<Vec<u8>> { +) -> Option<GraphicsOutput> { let (clipped, format_code) = clipped_placement(placement)?; let host_id = placement .host_image_id @@ -201,37 +164,30 @@ fn encode_placement_update( } let mut bytes = Vec::new(); - let mut displayed = false; + let mut output = GraphicsOutput::default(); if !image_current { - if cache.images.contains_key(&host_id) - && matches!(placement.source_key, HostSourceKey::PaneLayer { .. }) - { - if !encode_transmit_and_display( - &mut bytes, - placement, - clipped, - format_code, - host_id, - placement_id, - ) { - return None; - } - displayed = true; - } else { - if cache.images.contains_key(&host_id) { - encode_delete_image(&mut bytes, host_id); - cache.placements.retain(|(id, _), _| *id != host_id); - cache.replayed_placements.retain(|(id, _)| *id != host_id); - } - if !encode_upload_image(&mut bytes, placement, format_code, host_id) { + if cache.images.contains_key(&host_id) { + encode_delete_image(&mut bytes, host_id); + cache.placements.retain(|(id, _), _| *id != host_id); + cache.replayed_placements.retain(|(id, _)| *id != host_id); + } + if let Some(data) = &placement.raw_data { + if data.is_empty() { return None; } + output.push_bytes(std::mem::take(&mut bytes)); + output.operations.push(GraphicsOperation::Upload { + control: upload_control(placement, format_code, host_id), + data: Arc::clone(data), + }); + } else if !encode_upload_image(&mut bytes, placement, format_code, host_id) { + return None; } cache.images.insert(host_id, image_signature); } release_superseded_source_image(&mut bytes, cache, placement.source_key.clone(), host_id); - if !displayed && !placement_current { + if !placement_current { encode_display_placement( &mut bytes, clipped, @@ -244,7 +200,8 @@ fn encode_placement_update( if cache.replay_placements { cache.replayed_placements.insert(key); } - Some(bytes) + output.push_bytes(bytes); + Some(output) } fn release_superseded_source_image( @@ -265,13 +222,22 @@ fn release_superseded_source_image( cache.replayed_placements.retain(|(id, _)| *id != previous); } +#[cfg(test)] fn encode_graphics_update_incremental( cache: &mut HostGraphicsCache, placements: &[HostPlacement], - live_pane_sources: &HashSet<HostSourceKey>, - transaction_budget: Option<usize>, - coalesce_placements: bool, ) -> EncodedGraphics { + let (output, incomplete) = encode_graphics_output_incremental(cache, placements); + EncodedGraphics { + bytes: output.into_inline_bytes(), + incomplete, + } +} + +fn encode_graphics_output_incremental( + cache: &mut HostGraphicsCache, + placements: &[HostPlacement], +) -> (GraphicsOutput, bool) { let desired_sources = placements .iter() .map(|placement| placement.source_key.clone()) @@ -301,48 +267,12 @@ fn encode_graphics_update_incremental( .map(|index| index + 1) .or_else(|| cache.continuation.as_ref().map(|cursor| cursor.2)) .map_or(0, |index| index % placements.len().max(1)); - let mut bytes = Vec::new(); + let mut bytes = GraphicsOutput::default(); let mut emitted = false; - let mut dead_sources = cache + cache .sources - .keys() - .filter(|source| { - matches!(source, HostSourceKey::PaneLayer { .. }) - && !live_pane_sources.contains(*source) - }) - .cloned() - .collect::<Vec<_>>(); - dead_sources.sort_by_key(source_order); - for source in dead_sources { - let host_id = cache.sources[&source]; - let last_reference = !cache - .sources - .iter() - .any(|(other, id)| *other != source && *id == host_id); - if emitted && last_reference { - return EncodedGraphics { - bytes, - incomplete: true, - }; - } - cache.sources.remove(&source); - if last_reference { - encode_delete_image(&mut bytes, host_id); - cache.images.remove(&host_id); - cache.placements.retain(|(id, _), _| *id != host_id); - cache.replayed_placements.retain(|(id, _)| *id != host_id); - emitted = true; - } - } - cache.sources.retain(|source, _| { - matches!(source, HostSourceKey::PaneLayer { .. }) || desired_sources.contains(source) - }); - cache.oversized.retain(|source, _| { - matches!(source, HostSourceKey::Terminal { .. }) - || live_pane_sources.contains(source) - || desired_sources.contains(source) - }); + .retain(|source, _| desired_sources.contains(source)); let mut stale = cache .placements @@ -351,56 +281,22 @@ fn encode_graphics_update_incremental( .copied() .collect::<Vec<_>>(); stale.sort_unstable(); - let mut stale_image = None; for key @ (host_id, placement_id) in stale { let mut transaction = Vec::new(); encode_delete_placement(&mut transaction, host_id, placement_id); - let same_image = stale_image == Some(host_id); - if emitted - && !(coalesce_placements - && same_image - && coalesced_transaction_fits(bytes.len(), transaction.len(), transaction_budget)) - { - return EncodedGraphics { - bytes, - incomplete: true, - }; + if emitted { + return (bytes, true); } - bytes.extend(transaction); + bytes.push_bytes(transaction); cache.placements.remove(&key); cache.replayed_placements.remove(&key); emitted = true; - stale_image = Some(host_id); } - // Keep unrelated images isolated, but treat every row of one logical image - // as part of its upload or replacement transaction. Sending only the first - // row exposes the blank placeholder cells until later frames catch up. - let coalesce_pass = coalesce_placements && !emitted; - let mut coalesce_target = None; + // Preserve one cache mutation transaction per incremental call. for offset in 0..placements.len() { let index = (start + offset) % placements.len(); let placement = &placements[index]; - let signature = image_signature(placement, kitty_format_code(placement.placement.format)); - if transaction_budget.is_some() - && cache.oversized.get(&placement.source_key) == Some(&signature) - { - continue; - } - cache.oversized.remove(&placement.source_key); - let host_id = placement - .host_image_id - .unwrap_or_else(|| host_image_id(placement.pane_id, &placement.placement)); - let image_cached = cache.images.get(&host_id) == Some(&signature); - // With the image uploaded and the source already bound to it, the - // transaction is a re-display only: no upload and no superseded-image - // delete from `release_superseded_source_image`. - let pure_redisplay = - image_cached && cache.sources.get(&placement.source_key) == Some(&host_id); - if !image_cached && !image_transaction_fits(placement, transaction_budget) { - cache.quarantine_oversized(placement.source_key.clone(), signature); - continue; - } let mut candidate = cache.clone(); let Some(transaction) = encode_placement_update(&mut candidate, placement) else { continue; @@ -409,47 +305,26 @@ fn encode_graphics_update_incremental( *cache = candidate; continue; } - let same_logical_image = coalesce_target.as_ref().is_none_or(|(source, target_id)| { - source == &placement.source_key && *target_id == host_id - }); - if emitted - && !(coalesce_pass - && pure_redisplay - && same_logical_image - && coalesced_transaction_fits(bytes.len(), transaction.len(), transaction_budget)) - { - return EncodedGraphics { - bytes, - incomplete: true, - }; + if emitted { + return (bytes, true); } *cache = candidate; let (source, id) = placement_identity(placement); cache.continuation = Some((source, id, (index + 1) % placements.len())); bytes.extend(transaction); emitted = true; - if coalesce_pass && !pure_redisplay { - coalesce_target = Some((placement.source_key.clone(), host_id)); - } } cache.replay_placements = false; cache.replayed_placements.clear(); - EncodedGraphics { - bytes, - incomplete: false, - } + (bytes, false) } #[cfg(test)] -fn drain_graphics_updates( - cache: &mut HostGraphicsCache, - placements: &[HostPlacement], - live: &HashSet<HostSourceKey>, -) -> Vec<u8> { +fn drain_graphics_updates(cache: &mut HostGraphicsCache, placements: &[HostPlacement]) -> Vec<u8> { let mut bytes = Vec::new(); loop { - let encoded = encode_graphics_update_incremental(cache, placements, live, None, false); + let encoded = encode_graphics_update_incremental(cache, placements); bytes.extend(encoded.bytes); if !encoded.incomplete { return bytes; @@ -464,15 +339,6 @@ impl HostGraphicsCache { self.replayed_placements.clear(); } - fn quarantine_oversized(&mut self, source: HostSourceKey, signature: ImageSignature) { - if !self.oversized.contains_key(&source) && self.oversized.len() >= MAX_OVERSIZED_SOURCES { - if let Some(evicted) = self.oversized.keys().next().cloned() { - self.oversized.remove(&evicted); - } - } - self.oversized.insert(source, signature); - } - pub(crate) fn request_placement_replay(&mut self) { if !self.replay_placements { self.replay_placements = true; @@ -488,7 +354,6 @@ impl HostGraphicsCache { self.images.clear(); self.placements.clear(); self.sources.clear(); - self.oversized.clear(); self.reset_incremental_progress(); bytes } @@ -496,13 +361,10 @@ impl HostGraphicsCache { fn collect_visible_placements( app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, terminal_runtimes: &TerminalRuntimeRegistry, surface: crate::ui::TabSurfaceView<'_>, cell_size: HostCellSize, - uploaded_images: &HashMap<u32, ImageSignature>, - oversized_images: &HashMap<HostSourceKey, ImageSignature>, - client_id: u64, + delivered_images: &HashMap<HostSourceKey, ImageSignature>, ) -> Vec<HostPlacement> { let Some(target) = surface.target else { tracing::debug!("collect_visible_placements: no tab surface target"); @@ -531,33 +393,6 @@ fn collect_visible_placements( ); let mut placements = Vec::new(); for info in surface.pane_infos { - let mut pane_layers = graphics - .slots - .iter() - .filter_map(|((pane_id, layer_id), slot)| { - (*pane_id == info.id) - .then(|| { - slot.layer.as_ref().and_then(|layer| { - (!layer.terminal_only() || slot.direct_client() == Some(client_id)) - .then_some((layer_id, slot.host_image_id, layer)) - }) - }) - .flatten() - }) - .collect::<Vec<_>>(); - pane_layers.sort_by_key(|(layer_id, _, layer)| (layer.z_index, layer_id.as_str())); - for (layer_id, host_image_id, layer) in pane_layers { - placements.push(pane_graphics_host_placement( - info, - layer_id, - host_image_id, - cell_size, - layer, - uploaded_images, - true, - )); - } - let runtime = match app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { Some(rt) => rt, None => { @@ -567,19 +402,17 @@ fn collect_visible_placements( }; let mut requested_images = HashSet::new(); for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| { - terminal_image_needs_data( - info.id, - descriptor, - uploaded_images, - oversized_images, - &mut requested_images, - ) + if descriptor.source_file { + return false; + } + terminal_image_needs_data(info.id, descriptor, delivered_images, &mut requested_images) }) { let scrollback_offset = runtime .scroll_metrics() .map(|m| m.offset_from_bottom as u32) .unwrap_or(0); placements.push(HostPlacement { + raw_data: None, pane_id: info.id, host_image_id: None, area: info.inner_rect, @@ -603,97 +436,19 @@ fn collect_visible_placements( fn terminal_image_needs_data( pane_id: PaneId, descriptor: KittyImageDescriptor, - uploaded_images: &HashMap<u32, ImageSignature>, - oversized_images: &HashMap<HostSourceKey, ImageSignature>, + delivered_images: &HashMap<HostSourceKey, ImageSignature>, requested_images: &mut HashSet<(HostSourceKey, ImageSignature)>, ) -> bool { let format_code = kitty_format_code(descriptor.format); let signature = image_signature_from_descriptor(descriptor, format_code); - let host_id = host_image_id_for_signature(pane_id, signature); let source = HostSourceKey::Terminal { pane_id, image_id: descriptor.image_id, }; - uploaded_images.get(&host_id).copied() != Some(signature) - && oversized_images.get(&source).copied() != Some(signature) + delivered_images.get(&source).copied() != Some(signature) && requested_images.insert((source, signature)) } -fn pane_graphics_host_placement( - info: &PaneInfo, - layer_id: &str, - host_id: u32, - cell_size: HostCellSize, - layer: &crate::app::pane_graphics::Layer, - uploaded_images: &HashMap<u32, ImageSignature>, - include_data: bool, -) -> HostPlacement { - let format = pane_graphics_kitty_format(layer.format); - let signature = pane_layer_image_signature(layer); - let data = if !include_data || uploaded_images.get(&host_id).copied() == Some(signature) { - Vec::new() - } else { - layer.inline_data().map(<[u8]>::to_vec).unwrap_or_default() - }; - let render = layer.render; - let grid_cols = if render.grid_cols == 0 { - u32::from(info.inner_rect.width) - } else { - render.grid_cols - }; - let grid_rows = if render.grid_rows == 0 { - u32::from(info.inner_rect.height) - } else { - render.grid_rows - }; - - HostPlacement { - pane_id: info.id, - host_image_id: Some(host_id), - area: info.inner_rect, - cell_size, - source_key: HostSourceKey::PaneLayer { - pane_id: info.id, - layer_id: layer_id.to_owned(), - }, - scrollback_offset: 0, - placement: KittyImagePlacement { - image_id: 1, - placement_id: 1, - z: layer.z_index, - x_offset: 0, - y_offset: 0, - image_width: layer.image_width, - image_height: layer.image_height, - format, - data_len: layer.data_len(), - data_fingerprint: layer.data_fingerprint, - data, - render: KittyPlacementRenderInfo { - pixel_width: layer.image_width, - pixel_height: layer.image_height, - grid_cols, - grid_rows, - viewport_col: render.viewport_col, - viewport_row: render.viewport_row, - source_x: 0, - source_y: 0, - source_width: 0, - source_height: 0, - }, - }, - } -} - -fn pane_graphics_kitty_format(format: crate::api::schema::PaneGraphicsFormat) -> KittyImageFormat { - match format { - crate::api::schema::PaneGraphicsFormat::Png => KittyImageFormat::Png, - crate::api::schema::PaneGraphicsFormat::Rgb => KittyImageFormat::Rgb, - crate::api::schema::PaneGraphicsFormat::Rgba - | crate::api::schema::PaneGraphicsFormat::Bgra => KittyImageFormat::Rgba, - } -} - fn host_image_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 { let format_code = kitty_format_code(placement.format); host_image_id_for_signature( @@ -719,11 +474,6 @@ fn host_placement_id(source_key: &HostSourceKey, placement: &KittyImagePlacement let mut hasher = DefaultHasher::new(); match source_key { HostSourceKey::Terminal { pane_id, .. } => pane_id.raw().hash(&mut hasher), - HostSourceKey::PaneLayer { pane_id, layer_id } => { - "pane.graphics".hash(&mut hasher); - pane_id.raw().hash(&mut hasher); - layer_id.hash(&mut hasher); - } HostSourceKey::ClientSurface { scope, source } => { "client.surface".hash(&mut hasher); scope.hash(&mut hasher); @@ -735,11 +485,6 @@ fn host_placement_id(source_key: &HostSourceKey, placement: &KittyImagePlacement 1 + ((hasher.finish() as u32) % 900_000) } -pub(crate) struct DirectFileCommand { - pub(crate) leading: Vec<u8>, - pub(crate) control: String, -} - #[cfg(unix)] pub(crate) fn encode_kitty_regular_file( out: &mut Vec<u8>, @@ -754,7 +499,7 @@ pub(crate) fn encode_kitty_regular_file( out.extend_from_slice(b"\x1b8"); } -fn encode_delete_image(out: &mut Vec<u8>, id: u32) { +pub(crate) fn encode_delete_image(out: &mut Vec<u8>, id: u32) { let _ = write!(out, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\"); } @@ -765,45 +510,24 @@ fn encode_delete_placement(out: &mut Vec<u8>, host_id: u32, host_placement_id: u ); } -fn encode_upload_image( - out: &mut Vec<u8>, - placement: &HostPlacement, - format_code: u32, - host_id: u32, -) -> bool { - if placement.placement.data.is_empty() { - return false; - } - - let control = format!( +fn upload_control(placement: &HostPlacement, format_code: u32, host_id: u32) -> String { + format!( "a=t,t=d,f={format_code},s={},v={},i={host_id},q=2", placement.placement.image_width, placement.placement.image_height, - ); - encode_kitty_data(out, &control, &placement.placement.data); - true + ) } -fn encode_transmit_and_display( +fn encode_upload_image( out: &mut Vec<u8>, placement: &HostPlacement, - clipped: ClippedPlacement, format_code: u32, host_id: u32, - host_placement_id: u32, ) -> bool { if placement.placement.data.is_empty() { return false; } - let _ = write!(out, "\x1b[{};{}H", clipped.y + 1, clipped.x + 1); - let mut control = format!( - "a=T,t=d,f={format_code},s={},v={},i={host_id},p={host_placement_id},c={},r={},z={},C=1,q=2", - placement.placement.image_width, - placement.placement.image_height, - clipped.cols, - clipped.rows, - placement.placement.z, - ); - append_placement_controls(&mut control, clipped); + + let control = upload_control(placement, format_code, host_id); encode_kitty_data(out, &control, &placement.placement.data); true } @@ -993,16 +717,6 @@ fn scale_pixels(value: u32, source: u32, dest: u32) -> u32 { ((value as u64).saturating_mul(source as u64) / dest.max(1) as u64).min(u32::MAX as u64) as u32 } -fn pane_layer_image_signature(layer: &crate::app::pane_graphics::Layer) -> ImageSignature { - ImageSignature { - image_width: layer.image_width, - image_height: layer.image_height, - format_code: kitty_format_code(pane_graphics_kitty_format(layer.format)), - data_len: layer.data_len(), - data_fingerprint: layer.data_fingerprint, - } -} - fn image_signature(placement: &HostPlacement, format_code: u32) -> ImageSignature { ImageSignature { image_width: placement.placement.image_width, @@ -1056,27 +770,80 @@ fn kitty_format_code(format: KittyImageFormat) -> u32 { } fn encode_kitty_data(out: &mut Vec<u8>, control: &str, data: &[u8]) { + write_kitty_data(out, control, data).expect("writing to Vec cannot fail"); +} + +pub(crate) fn write_kitty_data( + out: &mut impl Write, + control: &str, + data: &[u8], +) -> std::io::Result<()> { let mut chunks = data.chunks(KITTY_CHUNK_BYTES).peekable(); let Some(first) = chunks.next() else { - return; + return Ok(()); }; let more = if chunks.peek().is_some() { 1 } else { 0 }; let encoded = base64::engine::general_purpose::STANDARD.encode(first); - let _ = write!(out, "\x1b_G{control},m={more};{encoded}\x1b\\"); + write!(out, "\x1b_G{control},m={more};{encoded}\x1b\\")?; while let Some(chunk) = chunks.next() { let more = if chunks.peek().is_some() { 1 } else { 0 }; let encoded = base64::engine::general_purpose::STANDARD.encode(chunk); - let _ = write!(out, "\x1b_Gm={more};{encoded}\x1b\\"); + write!(out, "\x1b_Gm={more};{encoded}\x1b\\")?; } + Ok(()) } #[cfg(test)] mod tests { use super::*; + #[test] + fn deferred_updates_match_inline_for_rgb_and_rgba() { + for format in [KittyImageFormat::Rgb, KittyImageFormat::Rgba] { + let mut first = test_placement(0, 0); + first.placement.format = format; + first.placement.data = vec![37; KITTY_CHUNK_BYTES * 2 + 7]; + first.placement.data_len = first.placement.data.len(); + let data: Arc<[u8]> = Arc::from(first.placement.data.clone()); + let mut second = test_placement(3, 0); + second.placement = first.placement.clone(); + second.placement.placement_id += 1; + let mut placements = [first, second]; + let mut inline_cache = HostGraphicsCache::default(); + let expected = drain_graphics_updates(&mut inline_cache, &placements); + for placement in &mut placements { + placement.placement.data.clear(); + placement.raw_data = Some(Arc::clone(&data)); + } + let mut cache = HostGraphicsCache::default(); + let mut output = GraphicsOutput::default(); + loop { + let (next, incomplete) = + encode_graphics_output_incremental(&mut cache, &placements); + output.extend(next); + if !incomplete { + break; + } + } + assert_eq!(output.clone().into_inline_bytes(), expected); + let uploads = output + .operations + .iter() + .filter_map(|op| match op { + GraphicsOperation::Upload { data, .. } => Some(data), + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(uploads.len(), 1); + assert!(Arc::ptr_eq(uploads[0], &data)); + assert_eq!(cache.placements.len(), 2); + } + } + fn test_placement(viewport_col: i32, viewport_row: i32) -> HostPlacement { HostPlacement { + raw_data: None, pane_id: PaneId::from_raw(1), host_image_id: None, area: Rect::new(0, 0, 20, 10), @@ -1101,7 +868,8 @@ mod tests { data_len: 30 * 30 * 4, data_fingerprint: 42, data: vec![255; 30 * 30 * 4], - render: KittyPlacementRenderInfo { + source_file: None, + render: crate::ghostty::KittyPlacementRenderInfo { pixel_width: 0, pixel_height: 0, grid_cols: 3, @@ -1117,15 +885,6 @@ mod tests { } } - fn pane_layer_placement(viewport_col: i32, viewport_row: i32) -> HostPlacement { - let mut placement = test_placement(viewport_col, viewport_row); - placement.source_key = HostSourceKey::PaneLayer { - pane_id: placement.pane_id, - layer_id: "primary".into(), - }; - placement - } - fn update( cache: &mut HostGraphicsCache, placements: &[HostPlacement], @@ -1135,13 +894,7 @@ mod tests { if replay { cache.request_placement_replay(); } - let live = cache - .sources - .keys() - .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - .cloned() - .collect::<HashSet<_>>(); - bytes.extend(drain_graphics_updates(cache, placements, &live)); + bytes.extend(drain_graphics_updates(cache, placements)); bytes } @@ -1158,16 +911,6 @@ mod tests { host_placement_id(&placement.source_key, &placement.placement), expected ); - assert_ne!( - host_placement_id( - &HostSourceKey::PaneLayer { - pane_id: placement.pane_id, - layer_id: "primary".into(), - }, - &placement.placement, - ), - expected - ); } #[cfg(unix)] @@ -1186,33 +929,6 @@ mod tests { assert!(text.ends_with("\x1b\\\x1b8")); } - #[test] - fn pane_graphics_image_ids_are_disjoint_from_terminal_image_ids() { - let placement = test_placement(0, 0); - let signature = image_signature(&placement, kitty_format_code(placement.placement.format)); - let terminal_id = host_image_id_for_signature(placement.pane_id, signature); - let mut graphics = crate::app::pane_graphics::Runtime::default(); - let primary = (placement.pane_id, "primary".into()); - let pane_graphics_id = graphics.reserve_image_id(&primary).unwrap(); - graphics.slots.insert( - primary.clone(), - crate::app::pane_graphics::Slot::test(pane_graphics_id, None), - ); - - assert_eq!(terminal_id & PANE_GRAPHICS_IMAGE_ID_BIT, 0); - assert_ne!(pane_graphics_id & PANE_GRAPHICS_IMAGE_ID_BIT, 0); - assert_eq!( - pane_graphics_id, - graphics.reserve_image_id(&primary).unwrap() - ); - assert_ne!( - pane_graphics_id, - graphics - .reserve_image_id(&(placement.pane_id, "toolbar".into())) - .unwrap() - ); - } - #[test] fn clipped_placement_handles_positive_viewport_without_wrapping() { let placement = test_placement(2, 2); @@ -1239,47 +955,6 @@ mod tests { assert_eq!(clipped.source_y, 10); } - #[test] - fn pane_graphics_layer_defaults_to_full_pane_grid() { - let info = PaneInfo { - id: PaneId::from_raw(9), - rect: Rect::new(0, 0, 12, 5), - inner_rect: Rect::new(2, 1, 8, 3), - scrollbar_rect: None, - borders: ratatui::widgets::Borders::NONE, - is_focused: true, - }; - let layer = crate::app::pane_graphics::Layer::inline( - crate::api::schema::PaneGraphicsFormat::Rgba, - 80, - 30, - vec![255; 80 * 30 * 4], - crate::api::schema::PaneGraphicsPlacementParams::default(), - 0, - ); - - let placement = pane_graphics_host_placement( - &info, - "primary", - PANE_GRAPHICS_IMAGE_ID_BIT | 1, - HostCellSize { - width_px: 10, - height_px: 10, - }, - &layer, - &HashMap::new(), - true, - ); - let (clipped, format_code) = clipped_placement(&placement).expect("visible layer"); - - assert_eq!(format_code, 32); - assert_eq!(clipped.x, 2); - assert_eq!(clipped.y, 1); - assert_eq!(clipped.cols, 8); - assert_eq!(clipped.rows, 3); - assert_eq!(placement.placement.data.len(), 80 * 30 * 4); - } - #[test] fn graphics_update_uploads_once_then_repositions_only() { let mut cache = HostGraphicsCache::default(); @@ -1335,30 +1010,6 @@ mod tests { #[test] fn changing_first_source_does_not_starve_second_source() { - let layers = |first| { - [(1, "a", first), (2, "b", 80)].map(|(id, name, fingerprint)| { - let mut placement = pane_layer_placement(0, 0); - placement.host_image_id = Some(PANE_GRAPHICS_IMAGE_ID_BIT | id); - placement.source_key = HostSourceKey::PaneLayer { - pane_id: placement.pane_id, - layer_id: name.into(), - }; - placement.placement.data_fingerprint = fingerprint; - placement - }) - }; - let initial = layers(42); - let live = initial.iter().map(|p| p.source_key.clone()).collect(); - let mut cache = HostGraphicsCache::default(); - assert!( - encode_graphics_update_incremental(&mut cache, &initial, &live, None, false).incomplete - ); - assert!( - encode_graphics_update_incremental(&mut cache, &layers(43), &live, None, false) - .incomplete - ); - assert_eq!(cache.images.len(), 2, "second source uploaded next"); - let terminal = |id| { let mut placement = test_placement(0, 0); placement.placement.image_id = id; @@ -1373,56 +1024,13 @@ mod tests { let mut cache = HostGraphicsCache::default(); for id in 1..=3 { assert!( - encode_graphics_update_incremental( - &mut cache, - &[terminal(id), terminal(99)], - &HashSet::new(), - None, - false, - ) - .incomplete + encode_graphics_update_incremental(&mut cache, &[terminal(id), terminal(99)]) + .incomplete ); } assert!(cache.sources.contains_key(&second)); } - #[test] - fn large_terminal_image_is_local_but_quarantined_headless() { - let placements = || { - let mut large = test_placement(0, 0); - large.placement.data_len = 24 * 1024 * 1024; - let mut later = test_placement(4, 0); - later.placement.image_id = 8; - later.source_key = HostSourceKey::Terminal { - pane_id: later.pane_id, - image_id: 8, - }; - [large, later] - }; - for (budget, expected) in [ - (None, (true, 1, 0)), - (Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), (false, 1, 1)), - ] { - let mut cache = HostGraphicsCache::default(); - let encoded = encode_graphics_update_incremental( - &mut cache, - &placements(), - &HashSet::new(), - budget, - false, - ); - assert!(String::from_utf8_lossy(&encoded.bytes).contains("a=t")); - assert_eq!( - ( - encoded.incomplete, - cache.images.len(), - cache.oversized.len() - ), - expected - ); - } - } - #[test] fn terminal_image_data_requests_deduplicate_and_reconsider_changed_signatures() { let pane_id = PaneId::from_raw(1); @@ -1434,13 +1042,13 @@ mod tests { format: KittyImageFormat::Rgba, data_len: 3456 * 2234 * 4, data_fingerprint: 42, + source_file: false, }; let mut requested = HashSet::new(); assert!(terminal_image_needs_data( pane_id, descriptor, &HashMap::new(), - &HashMap::new(), &mut requested, )); let mut second_placement = descriptor; @@ -1449,7 +1057,6 @@ mod tests { pane_id, second_placement, &HashMap::new(), - &HashMap::new(), &mut requested, )); @@ -1458,13 +1065,12 @@ mod tests { pane_id, image_id: descriptor.image_id, }; - let oversized = HashMap::from([(source, signature)]); + let delivered = HashMap::from([(source, signature)]); let mut requested = HashSet::new(); assert!(!terminal_image_needs_data( pane_id, descriptor, - &HashMap::new(), - &oversized, + &delivered, &mut requested, )); let mut changed = descriptor; @@ -1472,38 +1078,8 @@ mod tests { assert!(terminal_image_needs_data( pane_id, changed, - &HashMap::new(), - &oversized, + &delivered, &mut requested, )); } - - #[test] - fn maximum_pane_graphics_stream_payload_fits_client_graphics_frame() { - let mut placement = pane_layer_placement(0, 0); - placement.placement.format = KittyImageFormat::Png; - placement.placement.image_width = 1; - placement.placement.image_height = 1; - placement.placement.data = vec![1_u8; crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES]; - placement.placement.data_len = placement.placement.data.len(); - let (clipped, format_code) = clipped_placement(&placement).expect("visible placement"); - let host_id = host_image_id(placement.pane_id, &placement.placement); - let mut encoded = Vec::new(); - - assert!(encode_upload_image( - &mut encoded, - &placement, - format_code, - host_id, - )); - encode_display_placement(&mut encoded, clipped, host_id, 1, 0); - - let mut framed = Vec::new(); - crate::protocol::write_message( - &mut framed, - &crate::protocol::ServerMessage::Graphics { bytes: encoded }, - ) - .unwrap(); - assert!(framed.len() <= crate::protocol::MAX_GRAPHICS_FRAME_SIZE + 4); - } } diff --git a/src/kitty_graphics/output.rs b/src/kitty_graphics/output.rs new file mode 100644 index 00000000..134a1f08 --- /dev/null +++ b/src/kitty_graphics/output.rs @@ -0,0 +1,94 @@ +//! Ordered, local-only graphics output. Upload pixels stay raw until output I/O. +#[cfg(test)] +use std::io::{self, Write}; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub(crate) enum GraphicsOperation { + Bytes(Vec<u8>), + Upload { control: String, data: Arc<[u8]> }, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct GraphicsOutput { + pub(crate) operations: Vec<GraphicsOperation>, +} + +impl GraphicsOutput { + pub(crate) fn from_bytes(bytes: Vec<u8>) -> Self { + let mut output = Self::default(); + output.push_bytes(bytes); + output + } + + pub(crate) fn push_bytes(&mut self, bytes: Vec<u8>) { + if !bytes.is_empty() { + self.operations.push(GraphicsOperation::Bytes(bytes)); + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.operations.is_empty() + } + + pub(crate) fn extend(&mut self, other: Self) { + self.operations.extend(other.operations); + } + + #[cfg(test)] + pub(crate) fn into_inline_bytes(self) -> Vec<u8> { + let mut bytes = Vec::new(); + for operation in self.operations { + match operation { + GraphicsOperation::Bytes(next) if bytes.is_empty() => bytes = next, + GraphicsOperation::Bytes(next) => bytes.extend(next), + GraphicsOperation::Upload { control, data } => { + super::encode_kitty_data(&mut bytes, &control, &data); + } + } + } + bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inline_fallback_preserves_order_and_chunk_encoding() { + let data: Arc<[u8]> = Arc::from(vec![42; super::super::KITTY_CHUNK_BYTES + 1]); + let control = "a=t,t=d,f=24,s=1,v=1,i=7,q=2"; + let mut output = GraphicsOutput::default(); + assert!(output.is_empty()); + output.extend(GraphicsOutput::from_bytes(b"before".to_vec())); + output.operations.push(GraphicsOperation::Upload { + control: control.into(), + data: Arc::clone(&data), + }); + output.extend(GraphicsOutput::from_bytes(b"after".to_vec())); + let mut expected = b"before".to_vec(); + super::super::encode_kitty_data(&mut expected, control, &data); + expected.extend_from_slice(b"after"); + assert_eq!(output.into_inline_bytes(), expected); + } + + #[test] + fn inline_write_propagates_io_errors() { + struct Broken; + impl Write for Broken { + fn write(&mut self, _: &[u8]) -> io::Result<usize> { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + assert_eq!( + super::super::write_kitty_data(&mut Broken, "a=t,t=d,f=32", &[1, 2, 3, 4]) + .unwrap_err() + .kind(), + io::ErrorKind::BrokenPipe + ); + } +} diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index 4df05ffb..9e03d642 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -1,10 +1,13 @@ use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use super::output::GraphicsOutput; use ratatui::layout::Rect; use super::{ - clipped_placement, collect_visible_placements, encode_graphics_update_incremental, + clipped_placement, collect_visible_placements, encode_graphics_output_incremental, HostCellSize, HostGraphicsCache, HostPlacement, HostSourceKey, ImageSignature, }; use crate::ghostty::{ @@ -24,7 +27,15 @@ pub(crate) struct DeliveryCache { pending: bool, } +pub(crate) type SourceFiles = + HashMap<SurfaceGraphicsAssetKey, Arc<crate::pane_graphics_files::OwnedExport>>; + impl DeliveryCache { + pub(crate) fn forget_asset(&mut self, key: &SurfaceGraphicsAssetKey) { + self.assets.remove(key); + self.pending = true; + } + pub(crate) fn has_pending(&self) -> bool { self.pending } @@ -92,11 +103,13 @@ impl Occlusion { } } -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub(crate) struct ClientState { scope: String, scene: SurfaceGraphicsScene, - assets: HashMap<SurfaceGraphicsAssetKey, Vec<u8>>, + display_scene: SurfaceGraphicsScene, + native_image_ids: HashMap<SurfaceGraphicsAssetKey, u32>, + assets: HashMap<SurfaceGraphicsAssetKey, Arc<[u8]>>, host: HostGraphicsCache, trusted_direct: HashMap<SurfaceGraphicsAssetKey, u32>, reset_pending: bool, @@ -105,6 +118,21 @@ pub(crate) struct ClientState { } impl ClientState { + pub(crate) fn new() -> Self { + Self::default() + } + + fn image_id(&self, key: &SurfaceGraphicsAssetKey) -> u32 { + if matches!(key.source, SurfaceGraphicsSource::Terminal { .. }) { + return self + .native_image_ids + .get(key) + .copied() + .unwrap_or_else(|| native_host_image_id(&self.scope, key)); + } + host_image_id(&self.scope, key) + } + pub(crate) fn scope(&self) -> &str { &self.scope } @@ -115,6 +143,8 @@ impl ClientState { } self.scope = scope.to_owned(); self.scene = SurfaceGraphicsScene::default(); + self.display_scene = SurfaceGraphicsScene::default(); + self.native_image_ids.clear(); self.assets.clear(); self.trusted_direct.clear(); self.stale_images.clear(); @@ -122,18 +152,54 @@ impl ClientState { self.reset_pending = true; } + #[cfg(unix)] + pub(crate) fn accepts_direct_asset( + &self, + key: &SurfaceGraphicsAssetKey, + image_id: u32, + ) -> bool { + if matches!(key.source, SurfaceGraphicsSource::Terminal { .. }) { + return !self.stale_images.contains(&image_id) + && !self.forced_delete_images.contains(&image_id) + && (image_id == native_host_image_id(&self.scope, key) + || image_id == (native_host_image_id(&self.scope, key) ^ NATIVE_SLOT_BIT)) + && !self + .display_scene + .placements + .iter() + .map(|p| &p.asset) + .chain(self.display_scene.retained_assets.iter()) + .any(|visible| { + self.image_id(visible) == image_id + && self.host.images.get(&image_id) + == Some(&image_signature_from_asset(visible)) + }) + && (self + .scene + .placements + .iter() + .any(|placement| &placement.asset == key) + || self.scene.retained_assets.contains(key)); + } + image_id == host_image_id(&self.scope, key) + } + #[cfg(unix)] pub(crate) fn trust_direct_asset( &mut self, key: &SurfaceGraphicsAssetKey, image_id: u32, ) -> bool { - if image_id != host_image_id(&self.scope, key) { + if !self.accepts_direct_asset(key, image_id) { return false; } + if matches!(key.source, SurfaceGraphicsSource::Terminal { .. }) { + self.native_image_ids.insert(key.clone(), image_id); + } self.host .images .insert(image_id, image_signature_from_asset(key)); + self.refresh_display_scene(); if self .scene .placements @@ -182,18 +248,6 @@ impl ClientState { .map(|placement| placement.asset.clone()) .chain(scene.retained_assets.iter().cloned()) .collect::<HashSet<_>>(); - let previous = self - .scene - .placements - .iter() - .map(|placement| placement.asset.clone()) - .chain(self.scene.retained_assets.iter().cloned()) - .collect::<HashSet<_>>(); - self.stale_images.extend( - previous - .difference(&desired) - .map(|key| host_image_id(&self.scope, key)), - ); let unclaimed = self .trusted_direct .iter() @@ -206,15 +260,107 @@ impl ClientState { .iter() .map(|placement| placement.asset.clone()) .collect::<HashSet<_>>(); - self.assets.retain(|key, _| placed.contains(key)); for asset in std::mem::take(&mut scene.assets) { if asset.data.len() as u64 == asset.key.data_len && placed.contains(&asset.key) { - self.assets.insert(asset.key, asset.data); + self.assets.insert(asset.key, Arc::from(asset.data)); } } self.scene = scene; + self.refresh_display_scene(); } + fn refresh_display_scene(&mut self) { + let mut next = self.scene.clone(); + let previous_placements = self + .display_scene + .placements + .iter() + .map(|p| ((&p.asset.source, p.logical_placement_id, p.x, p.y), p)) + .collect::<HashMap<_, _>>(); + let previous_assets = self + .display_scene + .retained_assets + .iter() + .chain(self.display_scene.placements.iter().map(|p| &p.asset)) + .filter(|key| { + self.host.images.get(&self.image_id(key)) == Some(&image_signature_from_asset(key)) + }) + .map(|key| (&key.source, key)) + .collect::<HashMap<_, _>>(); + for placement in &mut next.placements { + let key = &placement.asset; + if !matches!(key.source, SurfaceGraphicsSource::Terminal { .. }) + || self.assets.contains_key(key) + || self.host.images.get(&self.image_id(key)) + == Some(&image_signature_from_asset(key)) + { + continue; + } + // A metadata-only replacement is not visible until its upload is ACKed. + // Reuse only an identical placement, never stale resize/scroll geometry. + if let Some(previous) = previous_placements + .get(&( + &key.source, + placement.logical_placement_id, + placement.x, + placement.y, + )) + .copied() + .filter(|previous| { + if self.host.images.get(&self.image_id(&previous.asset)) + != Some(&image_signature_from_asset(&previous.asset)) + { + return false; + } + let mut candidate = (*previous).clone(); + candidate.asset = key.clone(); + candidate == *placement + }) + { + placement.asset = previous.asset.clone(); + } + } + for key in &mut next.retained_assets { + if matches!(key.source, SurfaceGraphicsSource::Terminal { .. }) + && !self.assets.contains_key(key) + && self.host.images.get(&self.image_id(key)) + != Some(&image_signature_from_asset(key)) + { + if let Some(previous) = previous_assets.get(&key.source) { + *key = (*previous).clone(); + } + } + } + let desired = next + .placements + .iter() + .map(|p| &p.asset) + .chain(next.retained_assets.iter()) + .collect::<HashSet<_>>(); + let stale = self + .display_scene + .placements + .iter() + .map(|p| &p.asset) + .chain(self.display_scene.retained_assets.iter()) + .filter(|key| !desired.contains(key)) + .map(|key| self.image_id(key)) + .collect::<Vec<_>>(); + self.stale_images.extend(stale); + let owned = self + .scene + .placements + .iter() + .map(|p| &p.asset) + .chain(self.scene.retained_assets.iter()) + .chain(desired.iter().copied()) + .collect::<HashSet<_>>(); + self.assets.retain(|key, _| owned.contains(key)); + self.native_image_ids.retain(|key, _| owned.contains(key)); + self.display_scene = next; + } + + #[cfg(test)] pub(crate) fn encode( &mut self, visibility: Visibility, @@ -223,6 +369,18 @@ impl ClientState { cell_size: HostCellSize, occlusion: &Occlusion, ) -> Vec<u8> { + self.encode_output(visibility, main_origin, popup_origin, cell_size, occlusion) + .into_inline_bytes() + } + + pub(crate) fn encode_output( + &mut self, + visibility: Visibility, + main_origin: (u16, u16), + popup_origin: Option<(u16, u16)>, + cell_size: HostCellSize, + occlusion: &Occlusion, + ) -> GraphicsOutput { let mut bytes = self.take_pending_cleanup(); self.stale_images.sort_unstable(); self.stale_images.dedup(); @@ -238,69 +396,52 @@ impl ClientState { } if !cell_size.is_known() || self.scope.is_empty() { bytes.extend(self.host.clear_bytes()); - return bytes; + return GraphicsOutput::from_bytes(bytes); } let placements = self - .scene + .display_scene .placements .iter() .filter_map(|placement| { client_host_placement( &self.scope, placement, - self.assets.get(&placement.asset).map(Vec::as_slice), visibility, main_origin, popup_origin, cell_size, occlusion, ) + .map(|mut host| { + host.host_image_id = Some(self.image_id(&placement.asset)); + host.raw_data = self.assets.get(&placement.asset).map(Arc::clone); + host + }) }) .collect::<Vec<_>>(); + let mut output = GraphicsOutput::from_bytes(bytes); self.host.request_placement_replay(); loop { - let encoded = encode_graphics_update_incremental( - &mut self.host, - &placements, - &HashSet::new(), - None, - false, - ); - bytes.extend(encoded.bytes); - if !encoded.incomplete { - return bytes; + let (encoded, incomplete) = + encode_graphics_output_incremental(&mut self.host, &placements); + output.extend(encoded); + if !incomplete { + return output; } } } } -pub(crate) fn pane_layer_asset_key( - app: &crate::app::App, - key: &crate::app::pane_graphics::Key, - layer: &crate::app::pane_graphics::Layer, -) -> Option<SurfaceGraphicsAssetKey> { - let workspace_index = app - .state - .workspaces - .iter() - .position(|workspace| workspace.pane_state(key.0).is_some())?; - Some(SurfaceGraphicsAssetKey { - source: SurfaceGraphicsSource::PaneLayer { - pane_id: app.public_pane_id(workspace_index, key.0)?, - layer_id: key.1.clone(), - }, - image_width: layer.image_width, - image_height: layer.image_height, - format: match layer.format { - crate::api::schema::PaneGraphicsFormat::Rgb => SurfaceGraphicsFormat::Rgb, - crate::api::schema::PaneGraphicsFormat::Rgba - | crate::api::schema::PaneGraphicsFormat::Bgra => SurfaceGraphicsFormat::Rgba, - crate::api::schema::PaneGraphicsFormat::Png => SurfaceGraphicsFormat::Png, - }, - data_len: layer.data_len() as u64, - data_fingerprint: layer.data_fingerprint, - }) +pub(crate) const NATIVE_TRANSFER_BIT: u64 = 1 << 63; +pub(crate) const NATIVE_SLOT_BIT: u32 = 1 << 29; + +pub(crate) fn native_host_image_id(scope: &str, key: &SurfaceGraphicsAssetKey) -> u32 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + scope.hash(&mut hasher); + key.source.hash(&mut hasher); + // Keep native IDs separate from the direct API namespace. + 0x4000_0000 | (hasher.finish() as u32 & 0x1fff_ffff) } pub(crate) fn host_image_id(scope: &str, key: &SurfaceGraphicsAssetKey) -> u32 { @@ -310,6 +451,7 @@ pub(crate) fn host_image_id(scope: &str, key: &SurfaceGraphicsAssetKey) -> u32 { 10_000 + ((hasher.finish() as u32) % 900_000) } +#[cfg(all(test, unix))] pub(crate) fn direct_upload_control(scope: &str, key: &SurfaceGraphicsAssetKey) -> (u32, String) { let image_id = host_image_id(scope, key); ( @@ -329,10 +471,14 @@ pub(crate) fn collect_scene( popup_content_size: Option<(u16, u16)>, cell_size: HostCellSize, delivered: &DeliveryCache, - client_id: u64, -) -> (SurfaceGraphicsScene, DeliveryCache) { + _client_id: u64, +) -> (SurfaceGraphicsScene, DeliveryCache, SourceFiles) { if !cell_size.is_known() { - return (SurfaceGraphicsScene::default(), DeliveryCache::default()); + return ( + SurfaceGraphicsScene::default(), + DeliveryCache::default(), + SourceFiles::default(), + ); } let workspace_index = surface.target.map(|target| target.workspace_index); let mut targets = HashMap::new(); @@ -357,10 +503,9 @@ pub(crate) fn collect_scene( targets.insert(*pane_id, target.clone()); } - // Reconstruct only the small image-signature index expected by the existing - // collector. This prevents copying already-delivered image payloads on each - // pane-scaled render while keeping Ghostty as the authoritative image store. - let mut uploaded_images = HashMap::new(); + // Reconstruct the small source-signature index expected by the collector. + // This avoids copying already-delivered payloads while Ghostty remains the + // authoritative image store. let mut delivered_terminal_images = HashMap::new(); for key in &delivered.assets { let signature = image_signature_from_asset(key); @@ -379,13 +524,8 @@ pub(crate) fn collect_scene( ); } } - SurfaceGraphicsSource::PaneLayer { pane_id, layer_id } => { - if let Some(pane_id) = public_panes.get(pane_id) { - if let Some(slot) = app.pane_graphics.slots.get(&(*pane_id, layer_id.clone())) { - uploaded_images.insert(slot.host_image_id, signature); - } - } - } + // Frozen for old-server compatibility; current servers no longer collect layers. + SurfaceGraphicsSource::PaneLayer { .. } => {} SurfaceGraphicsSource::Terminal { target: SurfaceGraphicsTarget::Popup { .. }, .. @@ -394,13 +534,10 @@ pub(crate) fn collect_scene( } let mut host_placements = collect_visible_placements( &app.state, - &app.pane_graphics, &app.terminal_runtimes, surface, cell_size, - &uploaded_images, &delivered_terminal_images, - client_id, ); if let (Some(popup), Some((width, height)), Some((_, target))) = ( @@ -418,9 +555,10 @@ pub(crate) fn collect_scene( }, descriptor, ); - !delivered.assets.contains(&key) && requested.insert(key) + !descriptor.source_file && !delivered.assets.contains(&key) && requested.insert(key) }) { host_placements.push(HostPlacement { + raw_data: None, pane_id: popup.pane_id, host_image_id: None, area: Rect::new(0, 0, width, height), @@ -441,6 +579,7 @@ pub(crate) fn collect_scene( let mut placements = Vec::new(); let mut asset_data = HashMap::<SurfaceGraphicsAssetKey, Vec<u8>>::new(); + let mut sources = SourceFiles::default(); for mut placement in host_placements { if placements.len() == MAX_SURFACE_GRAPHICS_PLACEMENTS { break; @@ -453,21 +592,18 @@ pub(crate) fn collect_scene( target, image_id: *image_id, }, - HostSourceKey::PaneLayer { layer_id, .. } => { - let SurfaceGraphicsTarget::Pane { pane_id } = target else { - continue; - }; - SurfaceGraphicsSource::PaneLayer { - pane_id, - layer_id: layer_id.clone(), - } - } HostSourceKey::ClientSurface { .. } => continue, }; let Some((clipped, _)) = clipped_placement(&placement) else { continue; }; let asset = asset_key(source, &placement.placement); + if !delivered.assets.contains(&asset) { + if let Some(file) = placement.placement.source_file.take() { + sources.entry(asset.clone()).or_insert(file); + asset_data.entry(asset.clone()).or_default(); + } + } if !placement.placement.data.is_empty() { asset_data .entry(asset.clone()) @@ -507,7 +643,9 @@ pub(crate) fn collect_scene( if next.assets.contains(&key) { continue; } - let encoded_size = super::image_transfer_estimated_size(data.len()); + let encoded_size = super::image_transfer_estimated_size( + usize::try_from(key.data_len).unwrap_or(usize::MAX), + ); if encoded_size > super::HEADLESS_GRAPHICS_TRANSACTION_BUDGET { continue; } @@ -532,17 +670,8 @@ pub(crate) fn collect_scene( placement.x, ) }); - let mut retained_assets = app - .pane_graphics - .slots - .iter() - .filter_map(|(key, slot)| { - let layer = slot.layer.as_ref()?; - (slot.direct_client() == Some(client_id)) - .then(|| pane_layer_asset_key(app, key, layer))? - }) - .collect::<Vec<_>>(); - retained_assets.sort_by_key(|key| format!("{:?}", key.source)); + let retained_assets = Vec::new(); + sources.retain(|key, _| next.assets.contains(key)); ( SurfaceGraphicsScene { assets, @@ -550,6 +679,7 @@ pub(crate) fn collect_scene( retained_assets, }, next, + sources, ) } @@ -602,7 +732,6 @@ fn asset_key( fn client_host_placement( scope: &str, placement: &SurfaceGraphicsPlacement, - data: Option<&[u8]>, visibility: Visibility, main_origin: (u16, u16), popup_origin: Option<(u16, u16)>, @@ -653,6 +782,7 @@ fn client_host_placement( let cols = placement.cols.min(u32::from(u16::MAX)) as u16; let rows = placement.rows.min(u32::from(u16::MAX)) as u16; Some(HostPlacement { + raw_data: None, pane_id, host_image_id: Some(host_image_id), area: Rect::new( @@ -678,7 +808,8 @@ fn client_host_placement( }, data_len: usize::try_from(placement.asset.data_len).unwrap_or(usize::MAX), data_fingerprint: placement.asset.data_fingerprint, - data: data.unwrap_or_default().to_vec(), + source_file: None, + data: Vec::new(), render: KittyPlacementRenderInfo { pixel_width: placement.cols.saturating_mul(cell_size.width_px), pixel_height: placement.rows.saturating_mul(cell_size.height_px), @@ -752,6 +883,387 @@ mod tests { } } + #[test] + fn legacy_pane_layer_scene_still_uploads_and_places() { + let mut state = ClientState::default(); + state.set_scope("old-server-pane-layer"); + let mut image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 255], + ); + image.key.source = SurfaceGraphicsSource::PaneLayer { + pane_id: "pane".into(), + layer_id: "primary".into(), + }; + state.set_scene(scene(image, 2, 1)); + + let bytes = String::from_utf8(state.encode( + Visibility::Main, + (10, 5), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + &Occlusion::default(), + )) + .unwrap(); + + assert!(bytes.contains("a=t,t=d"), "{bytes:?}"); + assert!(bytes.contains("a=p,"), "{bytes:?}"); + assert!(bytes.contains("\u{1b}[7;13H"), "{bytes:?}"); + } + + #[test] + fn stable_ids_replace_native_pixels_and_delete_on_removal() { + use crate::kitty_graphics::GraphicsOperation; + let mut state = ClientState::default(); + state.set_scope("stable-native"); + let target = SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }; + let first = asset(target.clone(), 1, vec![1, 2, 3, 255]); + let id = state.image_id(&first.key); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + for revision in 1..=8 { + let next = asset(target.clone(), revision, vec![revision as u8, 2, 3, 255]); + assert_eq!(state.image_id(&next.key), id); + state.set_scene(scene(next, 0, 0)); + let output = + state.encode_output(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + let uploads = output + .operations + .iter() + .filter_map(|op| match op { + GraphicsOperation::Upload { control, data } => Some((control, data)), + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(uploads.len(), 1); + assert!(uploads[0].0.contains(&format!("i={id},"))); + assert_eq!(uploads[0].1.as_ref(), &[revision as u8, 2, 3, 255]); + let bytes = String::from_utf8(output.into_inline_bytes()).unwrap(); + let upload = bytes.find("a=t,").unwrap(); + let display = bytes.find("a=p,").unwrap(); + assert!(upload < display); + if revision > 1 { + assert!(bytes.find("a=d,d=I,").unwrap() < upload); + } + assert_eq!(state.host.images.len(), 1); + } + let mut other = first.key.clone(); + other.source = SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "other-pane".into(), + }, + image_id: 7, + }; + assert_ne!(state.image_id(&other), id); + other.source = SurfaceGraphicsSource::PaneLayer { + pane_id: "pane".into(), + layer_id: "layer".into(), + }; + assert_eq!(state.image_id(&other), host_image_id(state.scope(), &other)); + let legacy_id = state.image_id(&other); + other.data_fingerprint += 1; + assert_ne!(state.image_id(&other), legacy_id); + state.set_scene(SurfaceGraphicsScene::default()); + let bytes = state.encode(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + assert!(String::from_utf8(bytes) + .unwrap() + .contains(&format!("a=d,d=I,i={id}"))); + assert!(state.host.images.is_empty()); + } + + #[cfg(unix)] + #[test] + fn native_file_adoption_keeps_previous_frame_until_replacement_ack() { + let mut state = ClientState::default(); + state.set_scope("native-files"); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "p1".into(), + }, + 1, + vec![1, 2, 3, 255], + ); + let id = native_host_image_id(state.scope(), &image.key); + assert!(!state.accepts_direct_asset(&image.key, id)); + let mut metadata = scene(image.clone(), 0, 0); + metadata.assets.clear(); + state.set_scene(metadata); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + state.encode(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + assert!(state.trust_direct_asset(&image.key, id)); + let first = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(first.contains("a=p,")); + assert!(!first.contains("a=t,")); + let mut next = image.clone(); + next.key.data_fingerprint += 1; + let mut metadata = scene(next.clone(), 0, 0); + metadata.assets.clear(); + state.set_scene(metadata); + assert!(!state.trust_direct_asset(&image.key, id)); + let cleanup = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(!cleanup.contains("a=d,")); + assert!(cleanup.contains(&format!("a=p,i={id},"))); + assert!(!state.accepts_direct_asset(&next.key, id)); + let replacement_id = id ^ NATIVE_SLOT_BIT; + assert!(state.trust_direct_asset(&next.key, replacement_id)); + let placed = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(placed.contains(&format!("a=p,i={replacement_id},"))); + assert!(placed.contains(&format!("a=d,d=I,i={id},"))); + assert!(!placed.contains("a=t,")); + assert_eq!(state.host.images.len(), 1); + assert_eq!(state.native_image_ids.len(), 1); + // The vacated bank is reusable, but the visible bank cannot be overwritten. + assert!(state.accepts_direct_asset(&next.key, id)); + assert!(!state.accepts_direct_asset(&next.key, replacement_id)); + assert!(!state.accepts_direct_asset(&next.key, host_image_id(state.scope(), &next.key))); + } + + #[cfg(unix)] + #[test] + fn native_replacements_use_two_ids_without_blank_pending_frames() { + let mut state = ClientState::default(); + state.set_scope("double-buffer"); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let mut previous = None; + let mut ids = HashSet::new(); + for revision in 0..32 { + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + revision, + vec![revision as u8, 2, 3, 255], + ); + let base = native_host_image_id(state.scope(), &image.key); + let id = if revision % 2 == 0 { + base ^ NATIVE_SLOT_BIT + } else { + base + }; + ids.insert(id); + let mut metadata = scene(image.clone(), 0, 0); + metadata.assets.clear(); + state.set_scene(metadata.clone()); + for _ in 0..3 { + state.set_scene(metadata.clone()); + let pending = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(!pending.contains("a=d,"), "{pending:?}"); + if let Some(previous) = previous { + assert!(pending.contains(&format!("a=p,i={previous},"))); + assert!(!state.accepts_direct_asset(&image.key, previous)); + } + } + assert!(state.trust_direct_asset(&image.key, id)); + let swapped = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(swapped.contains(&format!("a=p,i={id},"))); + assert!(!swapped.contains(&format!("a=d,d=I,i={id},"))); + if let Some(previous) = previous { + assert!(swapped.contains(&format!("a=d,d=I,i={previous},"))); + } + assert_eq!(state.host.images.len(), 1); + assert_eq!(state.native_image_ids.len(), 1); + assert!(!state.accepts_direct_asset(&image.key, id)); + assert_eq!(id & 0x8000_0000, 0, "native IDs must not alias API IDs"); + previous = Some(id); + } + assert_eq!(ids.len(), 2); + state.set_scene(SurfaceGraphicsScene::default()); + let cleanup = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(cleanup.contains(&format!("a=d,d=I,i={},", previous.unwrap()))); + assert!(state.host.images.is_empty()); + assert!(state.native_image_ids.is_empty()); + } + + #[cfg(unix)] + #[test] + fn native_pending_replacement_failure_and_geometry_do_not_preserve_stale_frames() { + let mut state = ClientState::default(); + state.set_scope("replacement-fallback"); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 255], + ); + let base = native_host_image_id(state.scope(), &image.key); + let active = base ^ NATIVE_SLOT_BIT; + let mut metadata = scene(image.clone(), 0, 0); + metadata.assets.clear(); + state.set_scene(metadata); + state.encode(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + assert!(state.trust_direct_asset(&image.key, active)); + state.encode(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + let mut next = image.clone(); + next.key.data_fingerprint += 1; + let mut metadata = scene(next.clone(), 0, 0); + metadata.assets.clear(); + state.set_scene(metadata); + state.retire_direct_image(base); + let rejected = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(!rejected.contains(&format!("a=d,d=I,i={active},"))); + assert!(rejected.contains(&format!("a=p,i={active},"))); + state.set_scene(scene(next.clone(), 0, 0)); + let fallback = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(fallback.contains("a=t,")); + assert!(fallback.contains(&format!("a=p,i={base},"))); + assert!(fallback.contains(&format!("a=d,d=I,i={active},"))); + assert!(!state.accepts_direct_asset(&next.key, base)); + next.key.data_fingerprint += 1; + assert!(!state.accepts_direct_asset(&next.key, base)); + let mut moved = scene(next.clone(), 1, 0); + moved.assets.clear(); + state.set_scene(moved); + let geometry = String::from_utf8(state.encode( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )) + .unwrap(); + assert!(geometry.contains(&format!("a=d,d=I,i={base},"))); + assert!(!geometry.contains("a=p,")); + state.set_scene(SurfaceGraphicsScene::default()); + assert!(!state.trust_direct_asset(&next.key, active)); + } + + #[test] + fn deferred_client_reuses_resident_arc_and_orders_cleanup_before_upload() { + use super::super::output::GraphicsOperation; + let mut state = ClientState::default(); + state.set_scope("raw"); + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let key = image.key.clone(); + let mut graphics = scene(image, 0, 0); + let mut second = graphics.placements[0].clone(); + second.logical_placement_id += 1; + second.x = 2; + graphics.placements.push(second); + let mut replacement = graphics.clone(); + replacement.assets.clear(); + state.set_scene(graphics); + let resident = Arc::clone(&state.assets[&key]); + state.set_scene(replacement); + assert!(Arc::ptr_eq(&resident, &state.assets[&key])); + state.forced_delete_images.push(4242); + let cell = HostCellSize { + width_px: 8, + height_px: 16, + }; + let output = + state.encode_output(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + assert!( + matches!(&output.operations[0], GraphicsOperation::Bytes(bytes) + if String::from_utf8_lossy(bytes).contains("a=d,d=I,i=4242")) + ); + let uploads = output + .operations + .iter() + .filter_map(|op| match op { + GraphicsOperation::Upload { data, .. } => Some(data), + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(uploads.len(), 1); + assert!(Arc::ptr_eq(uploads[0], &resident)); + assert_eq!( + String::from_utf8(output.clone().into_inline_bytes()) + .unwrap() + .matches("a=p") + .count(), + 2 + ); + let replay = + state.encode_output(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + assert!(replay + .operations + .iter() + .all(|op| matches!(op, GraphicsOperation::Bytes(_)))); + } + #[test] fn occlusion_uses_final_pixel_bounds_and_strict_overlap() { let cell = HostCellSize { @@ -797,7 +1309,7 @@ mod tests { 1, vec![1, 2, 3, 4], ); - let id = host_image_id("occlusion", &image.key); + let id = state.image_id(&image.key); let mut graphics = scene(image.clone(), 0, 0); let mut second = graphics.placements[0].clone(); second.logical_placement_id = 4; @@ -907,12 +1419,12 @@ mod tests { cover.cover(Rect::new(0, 10, 80, 3)); cover.cover(Rect::new(60, 0, 20, 5)); } - let _ = state.encode(Visibility::Main, (0, 0), None, cell, &cover); + let _ = state.encode_output(Visibility::Main, (0, 0), None, cell, &cover); let mut samples = Vec::new(); for _ in 0..101 { let start = std::time::Instant::now(); for _ in 0..20 { - std::hint::black_box(state.encode( + std::hint::black_box(state.encode_output( Visibility::Main, (0, 0), None, @@ -923,7 +1435,57 @@ mod tests { samples.push(start.elapsed().as_nanos() / 20); } samples.sort_unstable(); - eprintln!("graphics occlusion panes={count} disjoint_overlays={covered} median_ns={} p95_ns={}", samples[50], samples[95]); + eprintln!( + "graphics occlusion panes={count} disjoint_overlays={covered} median_ns={} p95_ns={}", + samples[50], samples[95] + ); + } + #[cfg(unix)] + { + let mut state = ClientState::default(); + state.set_scope("swap-profile"); + state.set_scene(graphics.clone()); + state.encode_output(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + graphics.assets.clear(); + let mut samples = Vec::new(); + for revision in 1..=105 { + graphics.placements[0].asset.data_fingerprint = revision; + let key = graphics.placements[0].asset.clone(); + let base = native_host_image_id(state.scope(), &key); + let id = if revision % 2 == 1 { + base ^ NATIVE_SLOT_BIT + } else { + base + }; + let scene = graphics.clone(); + let start = std::time::Instant::now(); + state.set_scene(scene); + std::hint::black_box(state.encode_output( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )); + let checkpoint = state.clone(); + assert!(state.trust_direct_asset(&key, id)); + std::hint::black_box(state.encode_output( + Visibility::Main, + (0, 0), + None, + cell, + &Occlusion::default(), + )); + drop(checkpoint); + if revision > 5 { + samples.push(start.elapsed().as_nanos()); + } + } + samples.sort_unstable(); + eprintln!( + "graphics replacement panes={count} median_ns={} p95_ns={}", + samples[50], samples[95] + ); } } } @@ -1031,7 +1593,7 @@ mod tests { #[cfg(unix)] #[test] - fn trusted_direct_asset_is_placed_without_inline_reupload() { + fn legacy_direct_asset_is_placed_without_inline_reupload() { let mut state = ClientState::default(); state.set_scope("endpoint-a:boot-1"); let _ = state.encode( @@ -1044,14 +1606,18 @@ mod tests { }, &Occlusion::default(), ); - let image = asset( + let mut image = asset( SurfaceGraphicsTarget::Pane { pane_id: "w1:p1".into(), }, 15, vec![1, 2, 3, 4], ); - let image_id = host_image_id("endpoint-a:boot-1", &image.key); + image.key.source = SurfaceGraphicsSource::PaneLayer { + pane_id: "w1:p1".into(), + layer_id: "primary".into(), + }; + let image_id = state.image_id(&image.key); assert!(state.trust_direct_asset(&image.key, image_id)); let mut direct_scene = scene(image, 0, 0); direct_scene.assets.clear(); @@ -1085,7 +1651,7 @@ mod tests { 18, vec![1, 2, 3, 4], ); - let image_id = host_image_id("endpoint-a:boot-1", &image.key); + let image_id = state.image_id(&image.key); let mut direct_scene = scene(image.clone(), 0, 0); direct_scene.assets.clear(); state.set_scene(direct_scene); @@ -1119,7 +1685,7 @@ mod tests { 19, vec![1, 2, 3, 4], ); - let image_id = host_image_id("endpoint-a:boot-1", &image.key); + let image_id = state.image_id(&image.key); let mut active = scene(image.clone(), 0, 0); active.assets.clear(); state.set_scene(active.clone()); @@ -1235,18 +1801,22 @@ mod tests { #[cfg(unix)] #[test] - fn unclaimed_direct_asset_is_deleted_by_the_next_authoritative_scene() { + fn unclaimed_legacy_direct_asset_is_deleted_by_the_next_authoritative_scene() { let mut state = ClientState::default(); state.set_scope("endpoint-a:boot-1"); let _ = state.take_pending_cleanup(); - let image = asset( + let mut image = asset( SurfaceGraphicsTarget::Pane { pane_id: "w1:p1".into(), }, 16, vec![1, 2, 3, 4], ); - let image_id = host_image_id("endpoint-a:boot-1", &image.key); + image.key.source = SurfaceGraphicsSource::PaneLayer { + pane_id: "w1:p1".into(), + layer_id: "primary".into(), + }; + let image_id = state.image_id(&image.key); assert!(state.trust_direct_asset(&image.key, image_id)); state.set_scene(SurfaceGraphicsScene::default()); diff --git a/src/pane.rs b/src/pane.rs index bfeec638..7d42e9c0 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3654,6 +3654,13 @@ impl PaneRuntime { #[cfg(test)] impl PaneRuntime { + #[cfg(unix)] + pub(crate) fn test_enable_kitty_source_forwarding(&self) { + let mut core = self.terminal.ghostty.core.lock().unwrap(); + core.terminal.enable_kitty_graphics().unwrap(); + core.terminal.set_kitty_source_forwarding(true).unwrap(); + } + pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) { Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], 4) } diff --git a/src/pane/cursor.rs b/src/pane/cursor.rs index 2ba94312..30137730 100644 --- a/src/pane/cursor.rs +++ b/src/pane/cursor.rs @@ -24,9 +24,16 @@ enum DecscusrParseState { } impl DecscusrTracker { - pub(crate) fn observe(&mut self, bytes: &[u8]) { - for &byte in bytes { - self.observe_byte(byte); + pub(crate) fn observe(&mut self, mut bytes: &[u8]) { + while !bytes.is_empty() { + if matches!(self.state, DecscusrParseState::Ground) { + let Some(escape) = bytes.iter().position(|&byte| byte == 0x1b) else { + return; + }; + bytes = &bytes[escape..]; + } + self.observe_byte(bytes[0]); + bytes = &bytes[1..]; } } @@ -256,6 +263,72 @@ fn is_jump(settled: TerminalCursorState, current: TerminalCursorState) -> bool { mod tests { use super::*; + fn assert_decscusr_matches_bytewise(chunks: &[&[u8]]) { + let mut optimized = DecscusrTracker::default(); + let mut bytewise = DecscusrTracker::default(); + for chunk in chunks { + optimized.observe(chunk); + for &byte in *chunk { + bytewise.observe_byte(byte); + } + assert_eq!( + optimized.cursor_shape_overridden(), + bytewise.cursor_shape_overridden(), + "chunks: {chunks:?}" + ); + // Compare the complete parser state, including incomplete CSI parameters. + assert_eq!( + format!("{:?}", optimized.state), + format!("{:?}", bytewise.state), + "chunks: {chunks:?}" + ); + } + } + + #[test] + fn decscusr_bulk_search_matches_bytewise_control_sequences_and_splits() { + let cases: &[&[u8]] = &[ + b"", + b"plain text\n\twithout escapes", + b"text\x1b[1 qmore\x1b[0 qend", + b"\x1b[ q\x1b[2 q\x1b[3 q\x1b[4 q\x1b[5 q\x1b[6 q\x1b[7 q", + b"\x1b\x1b[1 q\x1b[2;9 q\x1b[0:4 q", + b"\x1b[1q\x1b[? q\x1b[12$ q\x1b[1\x00 q\x1b[2\xff q", + b"\x1b[12\x1b[5 q\x1b]0;title\x07\x1bPdata\x1b\\", + b"\x1b[1 q\x1b", + b"\x1b[1 q\x1b[", + b"\x1b[1 q\x1b[0 ", + ]; + for &bytes in cases { + for split in 0..=bytes.len() { + assert_decscusr_matches_bytewise(&[&bytes[..split], &[], &bytes[split..]]); + } + let chunks: Vec<_> = bytes.chunks(1).collect(); + assert_decscusr_matches_bytewise(&chunks); + } + } + + #[test] + fn decscusr_bulk_search_matches_bytewise_all_byte_values() { + let all_bytes: Vec<u8> = (0..=255).collect(); + for split in 0..=all_bytes.len() { + assert_decscusr_matches_bytewise(&[&all_bytes[..split], &all_bytes[split..]]); + } + // Exercise every possible byte in ground, escape, and partial CSI states. + let prefixes: &[&[u8]] = &[b"", b"\x1b", b"\x1b[", b"\x1b[2", b"\x1b[0 ", b"\x1b[2;"]; + for &prefix in prefixes { + for byte in 0..=255u8 { + let mut bytes = b"\x1b[1 q".to_vec(); + bytes.extend_from_slice(prefix); + bytes.push(byte); + bytes.extend_from_slice(b" qtext\x1b[0 q\x1b[6 q"); + for split in 0..=bytes.len() { + assert_decscusr_matches_bytewise(&[&bytes[..split], &bytes[split..]]); + } + } + } + } + fn cursor(x: u16, y: u16, visible: bool, shape: u8) -> TerminalCursorState { TerminalCursorState { x, diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 472fa7aa..85b8e780 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -63,8 +63,19 @@ fn is_ignored_string_intro(byte: u8) -> bool { impl DefaultColorOscTracker { pub(super) fn observe(&mut self, bytes: &[u8]) -> bool { let mut saw_default_color_set = false; - - for &byte in bytes { + let mut cursor = 0; + while cursor < bytes.len() { + if matches!( + self.state, + DefaultColorOscTrackerState::Ground | DefaultColorOscTrackerState::IgnoreString + ) { + let Some(offset) = bytes[cursor..].iter().position(|&byte| byte == 0x1b) else { + break; + }; + cursor += offset; + } + let byte = bytes[cursor]; + cursor += 1; match self.state { DefaultColorOscTrackerState::Ground => { if byte == 0x1b { @@ -157,7 +168,20 @@ pub(super) struct DefaultColorEventTracker { impl DefaultColorEventTracker { pub(super) fn observe(&mut self, bytes: &[u8]) { - for (index, &byte) in bytes.iter().enumerate() { + let mut cursor = 0; + while cursor < bytes.len() { + if matches!( + self.state, + DefaultColorOscTrackerState::Ground | DefaultColorOscTrackerState::IgnoreString + ) { + let Some(offset) = bytes[cursor..].iter().position(|&byte| byte == 0x1b) else { + break; + }; + cursor += offset; + } + let index = cursor; + let byte = bytes[cursor]; + cursor += 1; match self.state { DefaultColorOscTrackerState::Ground => { if byte == 0x1b { @@ -347,7 +371,19 @@ impl OscStreamCollector { const MAX_BODY_BYTES: usize = 4096; fn observe(&mut self, bytes: &[u8], mut receive: impl FnMut(&[u8])) { - for &byte in bytes { + let mut cursor = 0; + while cursor < bytes.len() { + if matches!( + self.state, + OscStreamState::Ground | OscStreamState::IgnoringString + ) { + let Some(offset) = bytes[cursor..].iter().position(|&byte| byte == 0x1b) else { + break; + }; + cursor += offset; + } + let byte = bytes[cursor]; + cursor += 1; match self.state { OscStreamState::Ground => { if byte == 0x1b { @@ -805,6 +841,59 @@ pub(super) fn restore_host_terminal_theme_if_needed( #[cfg(test)] mod tests { + #[test] + fn bulk_osc_scans_match_bytewise_state_and_response_offsets() { + use super::*; + let mut input = b"text\x1b_Gm=1;".to_vec(); + input.extend(std::iter::repeat_n(b'A', 8192)); + input.extend_from_slice(b"\x1b\\\x1b]10;?\x07\x1b]11;red\x1b\\\x1bPignored"); + input.extend(0u8..=255); + input.extend_from_slice(b"\x1b\\\x1b]12;"); + input.extend(std::iter::repeat_n(b'B', 4200)); + input.extend_from_slice(b"\x07\x1b]10;?\x1b\\\x1b]11;?\x07\x1b"); + for chunk_size in [1, 2, 3, 17, 4096, input.len()] { + let mut bulk = DefaultColorOscTracker::default(); + let mut scalar = DefaultColorOscTracker::default(); + let mut bulk_events = DefaultColorEventTracker::default(); + let mut scalar_events = DefaultColorEventTracker::default(); + let mut bulk_stream = OscStreamCollector::default(); + let mut scalar_stream = OscStreamCollector::default(); + for chunk in input.chunks(chunk_size) { + let changed = bulk.observe(chunk); + let mut scalar_changed = false; + let mut expected_events = Vec::new(); + let mut expected_bodies = Vec::new(); + for (offset, byte) in chunk.iter().enumerate() { + let byte = std::slice::from_ref(byte); + scalar_changed |= scalar.observe(byte); + scalar_events.observe(byte); + expected_events.extend(scalar_events.drain_pending().into_iter().map( + |mut event| { + event.end_offset += offset; + event + }, + )); + scalar_stream.observe(byte, |body| expected_bodies.push(body.to_vec())); + } + bulk_events.observe(chunk); + let mut bodies = Vec::new(); + bulk_stream.observe(chunk, |body| bodies.push(body.to_vec())); + assert_eq!(changed, scalar_changed); + assert_eq!((bulk.state, &bulk.body), (scalar.state, &scalar.body)); + assert_eq!(bulk_events.drain_pending(), expected_events); + assert_eq!( + (bulk_events.state, &bulk_events.body), + (scalar_events.state, &scalar_events.body) + ); + assert_eq!(bodies, expected_bodies); + assert_eq!( + (bulk_stream.state, &bulk_stream.body), + (scalar_stream.state, &scalar_stream.body) + ); + } + } + } + use tokio::sync::mpsc; use super::*; diff --git a/src/pane/xtgettcap.rs b/src/pane/xtgettcap.rs index 4d8fa572..ea6f3a09 100644 --- a/src/pane/xtgettcap.rs +++ b/src/pane/xtgettcap.rs @@ -38,7 +38,21 @@ enum C1XtgettcapTrackerState { impl C1XtgettcapQueryTracker { pub(super) fn observe(&mut self, bytes: &[u8]) { - for (index, &byte) in bytes.iter().enumerate() { + let mut index = 0; + while index < bytes.len() { + if self.state == C1XtgettcapTrackerState::IgnoreString { + // Payload cannot affect this state. CAN/SUB still need the + // global native-DCS bookkeeping when a dispatch is pending. + let next = bytes[index..].iter().position(|&byte| { + matches!(byte, 0x1b | 0x9c) + || (self.native_dcs_pending && matches!(byte, 0x18 | 0x1a)) + }); + match next { + Some(offset) => index += offset, + None => break, + } + } + let byte = bytes[index]; // With a 7-bit intro and raw ST, the native parser still holds the // DCS open. At its eventual unhook it can answer earlier keys in a // multi-key request again. Discard only that dispatch's XTGETTCAP @@ -164,6 +178,7 @@ impl C1XtgettcapQueryTracker { self.body.clear(); self.state = C1XtgettcapTrackerState::OversizedDcs; } + index += 1; } } @@ -239,3 +254,82 @@ fn append_upper_hex(bytes: &[u8], output: &mut Vec<u8>) { output.push(HEX[usize::from(byte & 0x0f)]); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn observe_chunks(chunks: &[&[u8]]) -> (C1XtgettcapQueryTracker, Vec<C1XtgettcapResponse>) { + let mut tracker = C1XtgettcapQueryTracker::default(); + let mut pending = Vec::new(); + let mut offset = 0; + for chunk in chunks { + tracker.observe(chunk); + pending.extend(tracker.drain_pending().into_iter().map(|mut response| { + response.end_offset += offset; + response + })); + offset += chunk.len(); + } + (tracker, pending) + } + + fn assert_chunk_equivalence(bytes: &[u8]) { + let one_byte_chunks: Vec<_> = bytes.chunks(1).collect(); + let (expected, expected_pending) = observe_chunks(&one_byte_chunks); + // Includes a single bulk call and every split, especially ESC / ST. + for split in 0..=bytes.len() { + let (actual, actual_pending) = observe_chunks(&[&bytes[..split], &bytes[split..]]); + assert_eq!(actual_pending, expected_pending, "split {split}: {bytes:?}"); + assert_eq!(actual.state, expected.state); + assert_eq!(actual.raw_c1_intro, expected.raw_c1_intro); + assert_eq!(actual.native_dcs_pending, expected.native_dcs_pending); + assert_eq!(actual.body, expected.body); + assert_eq!(actual.pending, expected.pending); + } + } + + #[test] + fn ignored_string_bulk_matches_single_bytes() { + let mut all_bytes = b"\x1b_Gordinary kitty payload".to_vec(); + all_bytes.extend(0..=255u8); + all_bytes.extend_from_slice(b"\x1b\\\x90+q5463\x9c\x90+q5247"); + assert_chunk_equivalence(&all_bytes); + + // Exercise every byte while actually inside IgnoreString, both with + // and without a native dispatch waiting for ESC, CAN, or SUB. + for native_pending in [false, true] { + for intro in [b"\x1b_".as_slice(), b"\x98", b"\x9e", b"\x9f"] { + for byte in 0..=255u8 { + let mut bytes = Vec::new(); + if native_pending { + bytes.extend_from_slice(b"\x1bP+q5463;524742\x9c"); + } + bytes.extend_from_slice(intro); + bytes.extend_from_slice(b"Gordinary payload"); + bytes.push(byte); + assert_chunk_equivalence(&bytes); + bytes.extend_from_slice(b"more payload\x1b\\\x90+q5463\x9c\x90+q5247"); + assert_chunk_equivalence(&bytes); + } + } + } + } + + #[test] + fn ignored_string_preserves_native_dispatch_offsets() { + for cancel in [0x18, 0x1a, 0x1b] { + let mut bytes = b"\x1bP+q5463\x9c\x9fignored payload".to_vec(); + bytes.push(cancel); + let end_offset = bytes.len(); + bytes.extend_from_slice(b"\x1b\\\x90+q524742\x9c"); + let (_, pending) = observe_chunks(&[&bytes]); + assert_eq!(pending.len(), 3); + assert!(pending[1].suppress_native); + assert!(pending[1].bytes.is_empty()); + assert_eq!(pending[1].end_offset, end_offset); + assert_eq!(pending[2].end_offset, bytes.len()); + assert_chunk_equivalence(&bytes); + } + } +} diff --git a/src/pane_graphics_files.rs b/src/pane_graphics_files.rs index 527be1d5..5c272bbe 100644 --- a/src/pane_graphics_files.rs +++ b/src/pane_graphics_files.rs @@ -1,13 +1,13 @@ #[cfg(unix)] use std::fs::OpenOptions; use std::fs::{self, File}; -use std::io; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; #[cfg(unix)] -use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::os::unix::fs::{DirBuilderExt, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}; #[cfg(unix)] const DIRECTORY_MODE: u32 = 0o700; @@ -19,6 +19,7 @@ pub(crate) struct FileStore { base: PathBuf, generation: OnceLock<Arc<Generation>>, next_fingerprint: AtomicU64, + native_budget: Option<Arc<Mutex<NativeBudget>>>, } #[derive(Debug)] @@ -27,13 +28,60 @@ struct Generation { source: PathBuf, } -#[derive(Clone, Debug)] -pub(crate) struct Lease { - inner: Arc<LeaseInner>, +/// Keep this owned snapshot alive until the terminal has consumed its path. +#[derive(Debug)] +pub(crate) struct OwnedExport { + lease: Lease, + _file: ExportFile, + _reservation: Option<Reservation>, +} + +// Also owns cleanup during writing/validation, before an export is published. +#[derive(Debug)] +struct ExportFile { + path: PathBuf, + _generation: Arc<Generation>, +} + +#[derive(Debug, Default)] +struct NativeBudget { + bytes: usize, + objects: usize, +} + +#[derive(Debug)] +struct Reservation { + budget: Arc<Mutex<NativeBudget>>, + len: usize, } +impl Reservation { + fn acquire(budget: &Arc<Mutex<NativeBudget>>, len: usize) -> io::Result<Self> { + 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 LeaseInner { +struct Lease { path: PathBuf, file: File, generation: Arc<Generation>, @@ -54,14 +102,53 @@ impl FileStore { base, generation: OnceLock::new(), next_fingerprint: AtomicU64::new(1), + native_budget: None, + } + } + + pub(crate) 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(crate) fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result<OwnedExport> { + 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, + }; + crate::platform::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), + }) } + #[cfg(all(test, unix))] pub(crate) fn source_directory(&self) -> io::Result<PathBuf> { Ok(self.generation()?.source.clone()) } - pub(crate) fn lease(&self, path: &Path, expected_len: usize) -> io::Result<Lease> { + fn lease(&self, path: &Path, expected_len: usize) -> io::Result<Lease> { let generation = self.generation()?; validate_child(path, &generation.source)?; let file = open_no_follow(path)?; @@ -70,14 +157,46 @@ impl FileStore { validate_path_identity(path, &metadata)?; let fingerprint = self.next_fingerprint.fetch_add(1, Ordering::Relaxed); Ok(Lease { - inner: Arc::new(LeaseInner { - path: path.to_owned(), - file, - generation, - metadata, - len: expected_len, - fingerprint, - }), + 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(crate) fn export(&self, data: &[u8]) -> io::Result<OwnedExport> { + 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, }) } @@ -96,39 +215,90 @@ impl FileStore { } } - #[cfg(test)] + #[cfg(all(test, unix))] pub(crate) fn is_initialized(&self) -> bool { self.generation.get().is_some() } } -impl Lease { +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(crate) fn copy_rgba(&self) -> io::Result<Vec<u8>> { + self.lease.copy_rgba() + } + + pub(crate) fn read_into(&self, data: &mut [u8]) -> io::Result<()> { + self.lease.read_into(data) + } + pub(crate) fn path(&self) -> &Path { - &self.inner.path + self.lease.path() } pub(crate) fn len(&self) -> usize { - self.inner.len + self.lease.len() } pub(crate) fn fingerprint(&self) -> u64 { - self.inner.fingerprint + self.lease.fingerprint() } +} - pub(crate) fn copy_rgba(&self) -> io::Result<Vec<u8>> { - let _keep_generation_alive = &self.inner.generation; - validate_path_identity(&self.inner.path, &self.inner.metadata)?; - let mut data = vec![0; self.inner.len]; - read_exact_at(&self.inner.file, &mut data)?; - if has_byte_at(&self.inner.file, self.inner.len as u64)? { +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!(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<Vec<u8>> { + 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.inner.file.metadata()?, self.inner.len)?; - validate_path_identity(&self.inner.path, &self.inner.metadata)?; - Ok(data) + validate_metadata(&self.file.metadata()?, self.len)?; + validate_path_identity(&self.path, &self.metadata)?; + Ok(()) } } @@ -144,12 +314,17 @@ impl Drop for Generation { #[cfg(unix)] pub(crate) 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(runtime_base().as_path()) + || generation.parent() != Some(base.as_path()) || !generation .file_name() .and_then(|name| name.to_str()) @@ -157,7 +332,7 @@ pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Re { return Err(invalid_path()); } - for directory in [runtime_base(), generation.to_owned(), source.to_owned()] { + for directory in [base, generation.to_owned(), source.to_owned()] { validate_directory(&directory)?; } let file = open_no_follow(path)?; @@ -166,6 +341,26 @@ pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Re validate_path_identity(path, &metadata) } +/// Unlike the API validator, accepts only the dedicated native hierarchy. +#[cfg(unix)] +pub(crate) 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<Generation> { #[cfg(not(unix))] { @@ -177,8 +372,16 @@ fn create_generation(base: &Path) -> io::Result<Generation> { } #[cfg(unix)] { - fs::create_dir_all(base)?; - fs::set_permissions(base, fs::Permissions::from_mode(DIRECTORY_MODE))?; + 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() @@ -275,6 +478,23 @@ fn validate_child(path: &Path, directory: &Path) -> io::Result<()> { Ok(()) } +fn create_export(path: &Path) -> io::Result<File> { + #[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<File> { #[cfg(unix)] { @@ -389,6 +609,277 @@ mod tests { 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(); diff --git a/src/platform/linux.rs b/src/platform/linux.rs index c73584e5..9be40488 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,3 +1,6 @@ +mod native_image_sources; +pub(crate) use native_image_sources::clone_native_image_source; + use std::{ collections::{HashSet, VecDeque}, io::{Read, Write}, diff --git a/src/platform/linux/native_image_sources.rs b/src/platform/linux/native_image_sources.rs new file mode 100644 index 00000000..b4d3b073 --- /dev/null +++ b/src/platform/linux/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<libc::stat> { + let mut result = std::mem::MaybeUninit::<libc::stat>::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/src/platform/mod.rs b/src/platform/mod.rs index 88f433bf..65717228 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -328,6 +328,8 @@ mod remote_bridge_tests; #[cfg(unix)] mod unix_common; #[cfg(unix)] +pub(crate) mod unix_image_files; +#[cfg(unix)] pub(crate) use unix_common::{ begin_cli_output, end_cli_output, forward_remote_bridge_stdio, RemoteBridgeWake, }; @@ -725,3 +727,16 @@ pub(crate) fn shared_ssh_control_path( "interactive SSH recovery requires Unix OpenSSH multiplexing", )) } + +/// Kernel CoW snapshots are deliberately unsupported outside Linux. +#[cfg(not(target_os = "linux"))] +pub(crate) fn clone_native_image_source( + _source_fd: i64, + _destination: &std::fs::File, + _expected_len: usize, +) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "native source cloning requires Linux", + )) +} diff --git a/src/platform/unix_image_files.rs b/src/platform/unix_image_files.rs new file mode 100644 index 00000000..795d4b10 --- /dev/null +++ b/src/platform/unix_image_files.rs @@ -0,0 +1,602 @@ +//! Unix-owned Kitty temporary-file ledger and bounded crash recovery. + +use std::fs::{self, DirBuilder, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +const MAX_FILES: usize = 8; +const MAX_BYTES: usize = 64 * 1024 * 1024; +const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024; +// Bound even abandoned generations: at most 512 MiB per Unix user. When all +// slots are live/recent, a new client uses inline rather than allocating more. +const MAX_GENERATIONS: usize = 8; +const STALE_GRACE: Duration = Duration::from_secs(60); +const GENERATION_PREFIX: &str = "herdr-tty-graphics-protocol-"; +const CONSUMPTION_TIMEOUT: Duration = Duration::from_secs(10); + +struct Pending { + path: PathBuf, + bytes: usize, + published: Instant, +} + +enum ProbeState { + Unsent, + Pending(PathBuf), + Consumed, +} + +pub(crate) struct Ledger { + root: PathBuf, + directory: Option<Generation>, + pending: Vec<Pending>, + next: u64, + disabled: bool, + probe: ProbeState, +} + +impl Ledger { + #[cfg(test)] + pub(crate) fn for_test(root: PathBuf) -> Self { + let mut ledger = Self::new(); + ledger.root = root; + ledger + } + + pub(crate) fn new() -> Self { + Self { + // Do not use TMPDIR/XDG_RUNTIME_DIR: a client override need not + // be in the terminal's accepted temporary-file roots. /tmp is + // explicitly accepted by Ghostty graphics_image.zig in v1.1.3, + // v1.2.3 and main, independent of its TMPDIR. /var/tmp is NOT + // generally accepted, so this bounded ledger must use /tmp. + root: PathBuf::from("/tmp"), + directory: None, + pending: Vec::new(), + next: 0, + disabled: false, + probe: ProbeState::Unsent, + } + } + + pub(crate) fn probe(&mut self) -> Option<PathBuf> { + if self.disabled || !matches!(self.probe, ProbeState::Unsent) { + return None; + } + let path = self.prepare_file_at(&[0, 0, 0, 0], Instant::now())?; + self.probe = ProbeState::Pending(path.clone()); + Some(path) + } + + pub(crate) fn prepare(&mut self, data: &[u8]) -> Option<PathBuf> { + self.prepare_at(data, Instant::now()) + } + + fn prepare_at(&mut self, data: &[u8], now: Instant) -> Option<PathBuf> { + self.reap(now); + if !matches!(self.probe, ProbeState::Consumed) { + return None; + } + self.prepare_file_at(data, now) + } + + fn prepare_file_at(&mut self, data: &[u8], now: Instant) -> Option<PathBuf> { + let bytes: usize = self.pending.iter().map(|entry| entry.bytes).sum(); + if self.disabled + || data.is_empty() + || data.len() > MAX_IMAGE_BYTES + || self.pending.len() >= MAX_FILES + || data.len() > MAX_BYTES - bytes + { + return None; + } + match self.write_file(data) { + Ok(path) => { + self.pending.push(Pending { + path: path.clone(), + bytes: data.len(), + published: now, + }); + Some(path) + } + Err(_) => { + // Do not repeatedly perform failing I/O on every frame. + self.disabled = true; + None + } + } + } + + fn reap(&mut self, now: Instant) { + if let ProbeState::Pending(path) = &self.probe { + if matches!(fs::symlink_metadata(path), Err(error) if error.kind() == io::ErrorKind::NotFound) + { + // With the allowlisted raw query this demonstrates local medium + // consumption, not arbitrary image acceptance or presentation. + self.probe = ProbeState::Consumed; + } + } + self.pending + .retain(|entry| match fs::symlink_metadata(&entry.path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + _ => true, // Permission/I/O errors are NOT evidence of consumption. + }); + if self + .pending + .iter() + .any(|entry| now.saturating_duration_since(entry.published) >= CONSUMPTION_TIMEOUT) + { + self.disabled = true; + } + } + + fn write_file(&mut self, data: &[u8]) -> io::Result<PathBuf> { + if self.directory.is_none() { + self.directory = Some(create_generation(&self.root, SystemTime::now())?); + } + let number = self.next; + self.next = self + .next + .checked_add(1) + .ok_or_else(|| io::Error::other("file sequence exhausted"))?; + let path = self + .directory + .as_ref() + .ok_or_else(|| io::Error::other("missing file generation"))? + .path + .join(format!("tty-graphics-protocol-{number}")); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path)?; + let result = file + .set_permissions(fs::Permissions::from_mode(0o600)) + .and_then(|()| file.write_all(data)); + // Close before publishing, including on write errors. No fsync is + // needed: the terminal reads the same filesystem/page cache. + drop(file); + if let Err(error) = result { + if fs::remove_file(&path).is_err() { + // Never published, but retain ownership if immediate + // cleanup failed so shutdown can retry the exact path. + self.pending.push(Pending { + path, + bytes: data.len(), + published: Instant::now(), + }); + } + return Err(error); + } + Ok(path) + } + + pub(crate) fn cleanup(&mut self) { + self.disabled = true; + for entry in self.pending.drain(..) { + let _ = fs::remove_file(entry.path); + } + if let Some(directory) = self.directory.take() { + // Never recursively delete: remove only paths we created. + let _ = fs::remove_dir(&directory.path); + } + } +} + +impl Drop for Ledger { + fn drop(&mut self) { + self.cleanup(); + } +} + +/// Keeping this directory descriptor locked prevents recovery by another client. +struct Generation { + path: PathBuf, + _lock: fs::File, +} + +fn effective_uid() -> u32 { + // SAFETY: geteuid takes no pointers and has no preconditions. + unsafe { libc::geteuid() } +} + +fn open_private_directory(path: &Path) -> io::Result<fs::File> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path)?; + let metadata = file.metadata()?; + if metadata.uid() != effective_uid() || metadata.mode() & 0o7777 != 0o700 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "untrusted image directory", + )); + } + Ok(file) +} + +fn lock_directory(file: &fs::File) -> io::Result<()> { + // SAFETY: this live File owns the descriptor; flock neither consumes it nor + // dereferences pointers. Drop releases the advisory lock automatically. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +fn create_generation(root: &Path, now: SystemTime) -> io::Result<Generation> { + let parent = root.join(format!("herdr-kitty-files-{}", effective_uid())); + match DirBuilder::new().mode(0o700).create(&parent) { + Ok(()) => fs::set_permissions(&parent, fs::Permissions::from_mode(0o700))?, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + // The persistent per-user parent is never removed: replacing its inode + // would split its lock domain. Other users cannot populate or rename it. + let parent_lock = open_private_directory(&parent)?; + lock_directory(&parent_lock)?; + if recover_generations(&parent, now)? >= MAX_GENERATIONS { + return Err(io::Error::other("image generation budget exhausted")); + } + let path = private_directory(&parent)?; + let result = open_private_directory(&path).and_then(|file| { + lock_directory(&file)?; + Ok(file) + }); + match result { + Ok(file) => Ok(Generation { path, _lock: file }), + Err(error) => { + let _ = fs::remove_dir(path); + Err(error) + } + } +} + +/// Caller holds the parent lock. Only unlocked, old generations can be removed; +/// terminal reads belonging to live clients are never invalidated by recovery. +/// Crash recovery is opportunistic at the next client's first file preparation. +fn recover_generations(parent: &Path, now: SystemTime) -> io::Result<usize> { + let entries = fs::read_dir(parent)? + .take(MAX_GENERATIONS + 1) + .collect::<io::Result<Vec<_>>>()?; + if entries.len() > MAX_GENERATIONS { + return Err(io::Error::other("unexpected image generation count")); + } + let mut remaining = entries.len(); + for entry in entries { + let name = entry.file_name(); + let Some(suffix) = name + .to_str() + .and_then(|name| name.strip_prefix(GENERATION_PREFIX)) + else { + continue; + }; + if suffix.len() != 32 || !suffix.bytes().all(|b| b.is_ascii_hexdigit()) { + continue; + } + let path = entry.path(); + let Ok(lock) = open_private_directory(&path) else { + continue; + }; + if lock_directory(&lock).is_err() { + continue; + } + let metadata = lock.metadata()?; + let old = metadata + .modified() + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= STALE_GRACE); + if old && remove_stale_generation(&path)? { + remaining -= 1; + } + } + Ok(remaining) +} + +/// Inspect the entire bounded set before deleting anything. Never follow links, +/// recurse, or remove unknown content, even inside our own namespace. +fn remove_stale_generation(path: &Path) -> io::Result<bool> { + let entries = fs::read_dir(path)? + .take(MAX_FILES + 1) + .collect::<io::Result<Vec<_>>>()?; + if entries.len() > MAX_FILES { + return Ok(false); + } + for entry in &entries { + let name = entry.file_name(); + let valid_name = name + .to_str() + .and_then(|name| name.strip_prefix("tty-graphics-protocol-")) + .is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())); + let metadata = fs::symlink_metadata(entry.path())?; + if !valid_name + || !metadata.is_file() + || metadata.uid() != effective_uid() + || metadata.mode() & 0o7777 != 0o600 + || metadata.nlink() != 1 + { + return Ok(false); + } + } + for entry in entries { + fs::remove_file(entry.path())?; + } + fs::remove_dir(path)?; + Ok(true) +} + +fn private_directory(root: &Path) -> io::Result<PathBuf> { + for _ in 0..8 { + let mut random = [0u8; 16]; + fs::File::open("/dev/urandom")?.read_exact(&mut random)?; + let name = u128::from_ne_bytes(random); + let path = root.join(format!("{GENERATION_PREFIX}{name:032x}")); + match DirBuilder::new().mode(0o700).create(&path) { + Ok(()) => { + if let Err(error) = fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) { + let _ = fs::remove_dir(&path); + return Err(error); + } + return Ok(path); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "temporary directory collisions", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestLedger { + ledger: Ledger, + scratch: PathBuf, + } + + impl std::ops::Deref for TestLedger { + type Target = Ledger; + fn deref(&self) -> &Ledger { + &self.ledger + } + } + + impl std::ops::DerefMut for TestLedger { + fn deref_mut(&mut self) -> &mut Ledger { + &mut self.ledger + } + } + + impl Drop for TestLedger { + fn drop(&mut self) { + self.ledger.cleanup(); + // Only this test's uniquely allocated private scratch tree. + let _ = fs::remove_dir_all(&self.scratch); + } + } + + fn test_ledger() -> TestLedger { + let scratch = private_directory(Path::new("/var/tmp")).unwrap(); + let mut ledger = Ledger::new(); + ledger.root = scratch.clone(); + // Most ledger tests start after successful query consumption. + ledger.probe = ProbeState::Consumed; + TestLedger { ledger, scratch } + } + + #[test] + fn private_complete_unique_files_and_shutdown() { + let mut ledger = test_ledger(); + let first = ledger.prepare(b"raw\0pixels").unwrap(); + let second = ledger.prepare(b"other").unwrap(); + let dir = first.parent().unwrap().to_owned(); + assert_ne!(first, second); + assert_eq!(fs::read(&first).unwrap(), b"raw\0pixels"); + assert_eq!( + fs::metadata(&first).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert!(first + .file_name() + .unwrap() + .to_str() + .unwrap() + .contains("tty-graphics-protocol")); + let mut other = test_ledger(); + assert_ne!( + other.prepare(b"other client").unwrap().parent().unwrap(), + dir + ); + drop(ledger); + assert!(!first.exists()); + assert!(!second.exists()); + assert!(!dir.exists()); + } + + #[test] + fn count_cap_and_terminal_consumption() { + let mut ledger = test_ledger(); + for _ in 0..MAX_FILES { + assert!(ledger.prepare(b"x").is_some()); + } + assert!(ledger.prepare(b"inline").is_none()); + let consumed = ledger.pending[0].path.clone(); + fs::remove_file(consumed).unwrap(); + assert!(ledger.prepare(b"replacement").is_some()); + assert_eq!(ledger.pending.len(), MAX_FILES); + } + + #[test] + fn byte_and_image_caps() { + let mut ledger = test_ledger(); + assert!(ledger.prepare(&[]).is_none()); + let mut data = vec![0; MAX_IMAGE_BYTES + 1]; + assert!(ledger.prepare(&data).is_none()); + assert!(ledger.directory.is_none()); + data.pop(); + for _ in 0..MAX_BYTES / MAX_IMAGE_BYTES { + assert!(ledger.prepare(&data).is_some()); + } + assert!(ledger.prepare(b"x").is_none()); + fs::remove_file(&ledger.pending[0].path).unwrap(); + assert!(ledger.prepare(&data).is_some()); + } + + #[test] + fn timeout_disables_without_unlinking_published_files() { + let mut ledger = test_ledger(); + let start = Instant::now(); + let path = ledger.prepare_at(b"pending", start).unwrap(); + assert!(ledger + .prepare_at(b"inline", start + CONSUMPTION_TIMEOUT) + .is_none()); + assert_eq!(fs::read(&path).unwrap(), b"pending"); + fs::remove_file(path).unwrap(); + assert!(ledger + .prepare_at(b"still inline", start + CONSUMPTION_TIMEOUT) + .is_none()); + } + + #[test] + fn local_creation_errors_fall_back_and_never_overwrite() { + let mut ledger = test_ledger(); + let path = ledger.prepare(b"published").unwrap(); + ledger.next = 0; // Force an exclusive-create collision. + assert!(ledger.prepare(b"must not overwrite").is_none()); + assert_eq!(fs::read(path).unwrap(), b"published"); + let mut broken = test_ledger(); + broken.root = ledger.pending[0].path.clone(); // Not a directory. + assert!(broken.prepare(b"inline").is_none()); + assert!(broken.disabled); + } + + #[test] + fn probe_is_once_bounded_and_required_before_real_uploads() { + let mut ledger = test_ledger(); + ledger.probe = ProbeState::Unsent; + assert!(ledger.prepare(b"real").is_none()); + assert!(ledger.directory.is_none()); + let query = ledger.probe().unwrap(); + assert_eq!(fs::read(&query).unwrap(), [0, 0, 0, 0]); + assert_eq!(ledger.pending.len(), 1); + assert_eq!(ledger.pending[0].bytes, 4); + assert!(ledger.probe().is_none()); + assert!(ledger.prepare(b"real").is_none()); + fs::remove_file(query).unwrap(); + assert!(ledger.prepare(b"real").is_some()); + assert!(ledger.probe().is_none()); + } + + #[test] + fn probe_timeout_preserves_file_and_permanently_disables_conversion() { + let mut ledger = test_ledger(); + ledger.probe = ProbeState::Unsent; + let query = ledger.probe().unwrap(); + let late = ledger.pending[0].published + CONSUMPTION_TIMEOUT; + assert!(ledger.prepare_at(b"real", late).is_none()); + assert!(ledger.disabled); + assert_eq!(fs::read(&query).unwrap(), [0, 0, 0, 0]); + fs::remove_file(query).unwrap(); + assert!(ledger.prepare_at(b"real", late).is_none()); + assert!(ledger.probe().is_none()); + } + + fn recover_for_test(parent: &Path, now: SystemTime) -> usize { + let lock = open_private_directory(parent).unwrap(); + lock_directory(&lock).unwrap(); + recover_generations(parent, now).unwrap() + } + + #[test] + fn recovery_preserves_live_and_recent_but_removes_abandoned_generations() { + let mut ledger = test_ledger(); + let image = ledger.prepare(b"live").unwrap(); + let generation_path = image.parent().unwrap().to_owned(); + let parent = generation_path.parent().unwrap(); + let later = SystemTime::now() + STALE_GRACE + Duration::from_secs(1); + assert_eq!(recover_for_test(parent, later), 1); + assert!(image.exists()); // Advisory lock protects live clients even if old. + let generation = ledger.directory.take().unwrap(); + ledger.pending.clear(); // Simulate process death without Drop cleanup. + drop(generation); + assert_eq!(recover_for_test(parent, SystemTime::now()), 1); + assert!(image.exists()); // Grace protects terminal work after a crash. + assert_eq!(recover_for_test(parent, later), 0); + assert!(!generation_path.exists()); + } + + #[test] + fn generation_cap_bounds_crash_leftovers_and_parent_lock_is_nonblocking() { + let fixture = test_ledger(); + let root = &fixture.scratch; + let now = SystemTime::now(); + let mut generations = Vec::new(); + for _ in 0..MAX_GENERATIONS { + generations.push(create_generation(root, now).unwrap()); + } + assert!(create_generation(root, now).is_err()); + let parent = generations[0].path.parent().unwrap().to_owned(); + let lock = open_private_directory(&parent).unwrap(); + lock_directory(&lock).unwrap(); + assert!(create_generation(root, now).is_err()); + drop(lock); + drop(generations); // Empty abandoned generations, as after an early crash. + assert!(create_generation(root, now).is_err()); + let recovered = + create_generation(root, now + STALE_GRACE + Duration::from_secs(1)).unwrap(); + assert_eq!(fs::read_dir(parent).unwrap().count(), 1); + drop(recovered); + } + + #[test] + fn recovery_rejects_symlinks_and_unknown_content() { + use std::os::unix::fs::symlink; + let mut ledger = test_ledger(); + let image = ledger.prepare(b"keep").unwrap(); + let generation_path = image.parent().unwrap().to_owned(); + let parent = generation_path.parent().unwrap(); + fs::remove_file(&image).unwrap(); + let outside = ledger.scratch.join("outside"); + fs::write(&outside, b"outside").unwrap(); + symlink(&outside, &image).unwrap(); + ledger.pending.clear(); + drop(ledger.directory.take()); + let later = SystemTime::now() + STALE_GRACE + Duration::from_secs(1); + assert_eq!(recover_for_test(parent, later), 1); + assert_eq!(fs::read(&outside).unwrap(), b"outside"); + fs::remove_file(&image).unwrap(); + fs::write(generation_path.join("unknown"), b"keep").unwrap(); + assert_eq!(recover_for_test(parent, later), 1); + assert!(generation_path.exists()); + } + + #[test] + fn existing_parent_must_be_owned_private_directory_not_symlink() { + use std::os::unix::fs::symlink; + let fixture = test_ledger(); + let parent = fixture + .scratch + .join(format!("herdr-kitty-files-{}", effective_uid())); + let destination = fixture.scratch.join("destination"); + fs::create_dir(&destination).unwrap(); + symlink(&destination, &parent).unwrap(); + assert!(create_generation(&fixture.scratch, SystemTime::now()).is_err()); + fs::remove_file(&parent).unwrap(); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(create_generation(&fixture.scratch, SystemTime::now()).is_err()); + } +} diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index aba166d6..e7fb2743 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -1190,9 +1190,55 @@ pub struct SurfaceGraphicsAssetKey { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SurfaceGraphicsAsset { pub key: SurfaceGraphicsAssetKey, + #[serde( + serialize_with = "serialize_graphics_bytes", + deserialize_with = "deserialize_graphics_bytes" + )] pub data: Vec<u8>, } +fn serialize_graphics_bytes<S: serde::Serializer>( + data: &[u8], + serializer: S, +) -> Result<S::Ok, S::Error> { + // Bincode's byte slice has the same length+bytes layout as Vec<u8>, + // but avoids per-byte serialization. Keep human-readable codecs unchanged. + if serializer.is_human_readable() { + data.serialize(serializer) + } else { + serializer.serialize_bytes(data) + } +} + +fn deserialize_graphics_bytes<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result<Vec<u8>, D::Error> { + if deserializer.is_human_readable() { + return Vec::<u8>::deserialize(deserializer); + } + + struct BytesVisitor; + impl<'de> serde::de::Visitor<'de> for BytesVisitor { + type Value = Vec<u8>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("image bytes") + } + + fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> { + Ok(bytes.to_vec()) + } + + fn visit_byte_buf<E: serde::de::Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> { + Ok(bytes) + } + } + + // The framed slice decoder checks the length against available input before + // handing bytes to the visitor, so a forged length cannot cause an allocation. + deserializer.deserialize_bytes(BytesVisitor) +} + /// One already-clipped desired placement relative to its target surface. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bincode::Decode)] pub struct SurfaceGraphicsPlacement { @@ -1733,6 +1779,85 @@ pub fn check_client_version(client_version: u32) -> VersionCheck { #[cfg(test)] mod tests { use super::*; + + #[test] + fn graphics_bulk_codec_preserves_legacy_wire_and_json() { + #[derive(Serialize, Deserialize)] + struct LegacyAsset { + key: SurfaceGraphicsAssetKey, + data: Vec<u8>, + } + for len in [0, 1, 250, 251, 65535, 65536, 800 * 480 * 4] { + let asset = SurfaceGraphicsAsset { + key: SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "pane-1".into(), + }, + image_id: 7, + }, + image_width: 800, + image_height: 480, + format: SurfaceGraphicsFormat::Rgba, + data_len: len as u64, + data_fingerprint: 42, + }, + data: (0..len).map(|i| (i % 256) as u8).collect(), + }; + let legacy = LegacyAsset { + key: asset.key.clone(), + data: asset.data.clone(), + }; + let config = bincode::config::standard(); + let before = bincode::serde::encode_to_vec(&legacy, config).unwrap(); + let after = bincode::serde::encode_to_vec(&asset, config).unwrap(); + assert_eq!(after, before); + let (decoded, used): (SurfaceGraphicsAsset, _) = + bincode::serde::decode_from_slice(&before, config).unwrap(); + assert_eq!(decoded, asset); + assert_eq!(used, before.len()); + let (decoded, used): (LegacyAsset, _) = + bincode::serde::decode_from_slice(&after, config).unwrap(); + assert_eq!(decoded.data, asset.data); + assert_eq!(decoded.key, asset.key); + assert_eq!(used, after.len()); + let json = serde_json::to_value(&legacy).unwrap(); + assert_eq!(serde_json::to_value(&asset).unwrap(), json); + assert_eq!( + serde_json::from_value::<SurfaceGraphicsAsset>(json).unwrap(), + asset + ); + assert!( + bincode::serde::decode_from_slice::<SurfaceGraphicsAsset, _>( + &before[..before.len() - 1], + config + ) + .is_err() + ); + } + } + #[test] + fn graphics_bulk_decode_rejects_truncated_and_forged_lengths() { + #[derive(Debug, Deserialize)] + struct Bytes(#[serde(deserialize_with = "deserialize_graphics_bytes")] Vec<u8>); + let config = bincode::config::standard(); + let original: Vec<u8> = (0..=255).collect(); + let encoded = bincode::serde::encode_to_vec(&original, config).unwrap(); + for end in 0..encoded.len() { + assert!( + bincode::serde::decode_from_slice::<Bytes, _>(&encoded[..end], config).is_err() + ); + } + let (decoded, consumed): (Bytes, _) = + bincode::serde::decode_from_slice(&encoded, config).unwrap(); + assert_eq!(decoded.0, original); + assert_eq!(consumed, encoded.len()); + for length in [MAX_GRAPHICS_FRAME_SIZE as u64 + 1, u64::MAX] { + let forged = bincode::serde::encode_to_vec(length, config).unwrap(); + assert!(bincode::serde::decode_from_slice::<Bytes, _>(&forged, config).is_err()); + } + } + use ratatui::style::{Color, Modifier}; use sha2::{Digest, Sha256}; diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index d542896a..d79c89cf 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -274,6 +274,7 @@ pub(super) struct RenderedPaneSurface { pub(super) popup: Option<Box<protocol::ClientShellPopupSurface>>, pub(super) graphics: protocol::SurfaceGraphicsScene, pub(super) graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, + pub(super) graphics_sources: crate::kitty_graphics::surface::SourceFiles, } #[derive(Debug)] @@ -438,16 +439,17 @@ pub(super) fn render_pane_surface( let popup = show_popup .then(|| render_popup_surface(app, area, resize_panes, cell_size)) .flatten(); - let (graphics, next_graphics_delivery) = crate::server::client_shell_graphics::collect( - app, - &layout.pane_infos, - &layout.split_borders, - popup.as_deref(), - target, - cell_size, - graphics_delivery, - client_id, - ); + let (graphics, next_graphics_delivery, graphics_sources) = + crate::server::client_shell_graphics::collect( + app, + &layout.pane_infos, + &layout.split_borders, + popup.as_deref(), + target, + cell_size, + graphics_delivery, + client_id, + ); if let Some(target) = target { for (&pane_id, &(epoch, _)) in &content_revisions_before { if let Some(runtime) = app.state.runtime_for_pane_in_workspace( @@ -488,6 +490,7 @@ pub(super) fn render_pane_surface( popup, graphics, graphics_delivery: next_graphics_delivery, + graphics_sources, }) } diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs index 00355230..d0af03a9 100644 --- a/src/server/client_shell_graphics.rs +++ b/src/server/client_shell_graphics.rs @@ -1,4 +1,4 @@ -use crate::kitty_graphics::surface::DeliveryCache; +use crate::kitty_graphics::surface::{DeliveryCache, SourceFiles}; use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene}; pub(crate) fn collect_retained( @@ -8,7 +8,7 @@ pub(crate) fn collect_retained( cell_size: crate::kitty_graphics::HostCellSize, delivered: &DeliveryCache, client_id: u64, -) -> Option<(SurfaceGraphicsScene, DeliveryCache)> { +) -> Option<(SurfaceGraphicsScene, DeliveryCache, SourceFiles)> { let rect = |rect: crate::protocol::SurfaceRect| { ratatui::layout::Rect::new(rect.x, rect.y, rect.width, rect.height) }; @@ -52,7 +52,7 @@ pub(crate) fn collect( cell_size: crate::kitty_graphics::HostCellSize, delivered: &DeliveryCache, client_id: u64, -) -> (SurfaceGraphicsScene, DeliveryCache) { +) -> (SurfaceGraphicsScene, DeliveryCache, SourceFiles) { let popup_content_size = popup.map(|popup| (popup.frame.width, popup.frame.height)); crate::kitty_graphics::surface::collect_scene( app, diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 0d297fb9..465408f5 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -132,6 +132,24 @@ impl ClientWriter { self.render.queue.discard_pending_render(); } + #[cfg(all(test, unix))] + pub(crate) fn test_paused() -> Self { + let queue = ClientWriterQueue::new(); + Self { + control: ClientControlWriter::queue(queue.clone()), + render: ClientRenderWriter::queue(queue), + } + } + + #[cfg(all(test, unix))] + pub(crate) fn test_drain(&self) -> Vec<Vec<u8>> { + let mut state = self.render.queue.lock_state(); + let mut frames = state.control.drain(..).collect::<Vec<_>>(); + frames.extend(state.ordered.drain(..)); + frames.extend(state.render.take()); + frames + } + #[cfg(test)] pub(crate) fn test_fill_render(&self, data: Vec<u8>) { self.render.try_send(data).unwrap(); diff --git a/src/server/headless.rs b/src/server/headless.rs index bba59498..fe8a67e8 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -76,8 +76,8 @@ mod bootstrap; mod client_views; mod endpoint_requests; mod lifecycle; +mod native_graphics; mod notifications; -mod pane_graphics; mod render; mod retained_surface; mod surface_interest; @@ -132,31 +132,16 @@ enum LoopEvent { RenderRequested, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +/// Presentation work caused by a server event. +/// +/// Keeping this classification separate from event delivery makes the input +/// contract explicit: events may reach a PTY without necessarily repainting. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RenderImpact { - #[default] None, - Graphics, Full, } -impl RenderImpact { - fn merge(&mut self, other: Self) { - *self = (*self).max(other); - } -} - -fn record_render_impact(source: &'static str, impact: RenderImpact) { - let event = match (source, impact) { - ("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests", - ("api_requests", RenderImpact::Full) => "full_render_cause.api_requests", - ("server_events", RenderImpact::Graphics) => "graphics_render_cause.server_events", - ("server_events", RenderImpact::Full) => "full_render_cause.server_events", - _ => return, - }; - crate::render_prof::event(event); -} - // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -205,6 +190,7 @@ pub struct HeadlessServer { client_socket_path: PathBuf, client_socket_identity: SocketFileIdentity, clients: HashMap<u64, ClientConnection>, + native_graphics: native_graphics::NativeGraphics, #[cfg(unix)] next_client_id: u64, /// The client currently driving session-wide host presentation and side effects. @@ -350,6 +336,7 @@ impl HeadlessServer { client_socket_path: client_path, client_socket_identity, clients: HashMap::new(), + native_graphics: Default::default(), #[cfg(unix)] next_client_id: 1, foreground_client_id: None, @@ -463,22 +450,7 @@ impl HeadlessServer { } // 3. Drain API requests. - if self.pane_graphics_runtime_active() { - let api_impact = self.drain_api_requests_with_render_impact(); - record_render_impact("api_requests", api_impact); - match api_impact { - RenderImpact::None => {} - RenderImpact::Graphics => { - needs_render = true; - needs_graphics_render = true; - } - RenderImpact::Full => { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - } - } - } else if self.drain_api_requests_with_shutdown_check() { + if self.drain_api_requests_with_shutdown_check() { needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.api_requests"); @@ -494,22 +466,7 @@ impl HeadlessServer { self.accept_client_connections()?; // 5. Drain server events from client threads. - if self.pane_graphics_runtime_active() { - let server_impact = self.drain_server_events_with_render_impact(); - record_render_impact("server_events", server_impact); - match server_impact { - RenderImpact::None => {} - RenderImpact::Graphics => { - needs_render = true; - needs_graphics_render = true; - } - RenderImpact::Full => { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - } - } - } else if self.drain_server_events() { + if self.drain_server_events() { needs_render = true; needs_full_render = true; crate::render_prof::event("full_render_cause.server_events"); @@ -541,13 +498,10 @@ impl HeadlessServer { crate::render_prof::event("full_render_cause.default_workspace"); } - if self.retain_live_pane_graphics() { - needs_render = true; - needs_graphics_render = true; - } - if self.expire_direct_graphics(now) { + if self.expire_native_graphics(now) { needs_render = true; - needs_graphics_render = true; + needs_full_render = true; + needs_graphics_render = false; } self.drain_client_config_reload_request(); @@ -689,43 +643,13 @@ impl HeadlessServer { } } LoopEvent::Api(msg) => { - if self.pane_graphics_runtime_active() { - let impact = self.handle_api_request_with_render_impact(*msg); - record_render_impact("api_requests", impact); - match impact { - RenderImpact::None => {} - RenderImpact::Graphics => { - needs_render = true; - needs_graphics_render = true; - } - RenderImpact::Full => { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - } - } - } else if self.handle_api_request_with_shutdown_check(*msg) { + if self.handle_api_request_with_shutdown_check(*msg) { needs_render = true; needs_full_render = true; } } LoopEvent::ServerEvent(ev) => { - if self.pane_graphics_runtime_active() { - let impact = self.handle_server_event_with_render_impact(ev); - record_render_impact("server_events", impact); - match impact { - RenderImpact::None => {} - RenderImpact::Graphics => { - needs_render = true; - needs_graphics_render = true; - } - RenderImpact::Full => { - needs_render = true; - needs_full_render = true; - needs_graphics_render = false; - } - } - } else if self.handle_server_event(ev) { + if self.handle_server_event_with_render_impact(ev) == RenderImpact::Full { needs_render = true; needs_full_render = true; } @@ -825,15 +749,11 @@ impl HeadlessServer { } fn sync_foreground_client_state(&mut self) { - self.app.direct_graphics_available = self.direct_graphics_available(); self.app.pixel_mouse_available = self.foreground_client_id.is_some_and(|id| { self.clients .get(&id) .is_some_and(|client| client.pixel_mouse) }); - if !self.app.direct_graphics_available { - self.retire_all_direct_graphics(); - } let Some(client_id) = self.foreground_client_id else { self.effective_size = self.headless_size; self.app.state.outer_terminal_focus = None; @@ -966,53 +886,12 @@ impl HeadlessServer { }) } - fn existing_direct_graphics_client(&self) -> Option<u64> { - self.app - .pane_graphics - .slots - .values() - .filter_map(crate::app::pane_graphics::Slot::direct_client) - .filter_map(|client_id| { - let client = self.clients.get(&client_id)?; - self.client_supports_direct_graphics(client_id) - .then_some((client.last_activity, client_id)) - }) - .max() - .map(|(_, client_id)| client_id) - } - - fn direct_graphics_client(&self) -> Option<u64> { - self.existing_direct_graphics_client().or_else(|| { - self.foreground_client_id - .filter(|client_id| self.client_supports_direct_graphics(*client_id)) - }) - } - - fn direct_graphics_client_for_key(&self, key: &crate::app::pane_graphics::Key) -> Option<u64> { - match self - .app - .pane_graphics - .slots - .get(key) - .and_then(crate::app::pane_graphics::Slot::direct_client) - { - Some(client_id) => self - .client_supports_direct_graphics(client_id) - .then_some(client_id), - None => self.direct_graphics_client(), - } - } - - fn direct_graphics_available(&self) -> bool { - self.direct_graphics_client().is_some() - } - fn has_app_client(&self) -> bool { self.app_client_count() > 0 } fn remove_client(&mut self, client_id: u64) -> bool { - self.retire_direct_graphics_for_client(client_id); + self.disconnect_native_graphics(client_id); let disconnected_focus = self .clients .get(&client_id) @@ -1052,7 +931,6 @@ impl HeadlessServer { self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost); } } - self.app.direct_graphics_available = self.direct_graphics_available(); if was_foreground { self.promote_latest_remaining_client() } else { @@ -1131,31 +1009,17 @@ impl HeadlessServer { } /// Drains server events from the dedicated channel. - /// - /// Uses the original full-render semantics when pane graphics are dormant. fn drain_server_events(&mut self) -> bool { let mut changed = false; while !self.should_quit.load(Ordering::Acquire) { let Ok(ev) = self.server_event_rx.try_recv() else { break; }; - changed |= self.handle_server_event(ev); + changed |= self.handle_server_event_with_render_impact(ev) == RenderImpact::Full; } changed } - /// Returns the strongest render impact from the drained event batch. - fn drain_server_events_with_render_impact(&mut self) -> RenderImpact { - let mut impact = RenderImpact::None; - while !self.should_quit.load(Ordering::Acquire) { - let Ok(ev) = self.server_event_rx.try_recv() else { - break; - }; - impact.merge(self.handle_server_event_with_render_impact(ev)); - } - impact - } - async fn reject_late_client_connections(&mut self) { self.server_event_rx.close(); while let Some(event) = self.server_event_rx.recv().await { @@ -2112,12 +1976,17 @@ impl HeadlessServer { transfer_id, image_id, success, - } => self.complete_direct_graphics(client_id, transfer_id, image_id, success), + } => self + .native_result(client_id, transfer_id, image_id, success) + .unwrap_or(false), ServerEvent::GraphicsTransmissionStarted { client_id, transfer_id, image_id, - } => self.start_direct_graphics_response(client_id, transfer_id, image_id), + } => { + self.native_started(client_id, transfer_id, image_id); + false + } ServerEvent::ClientAttachTerminal { client_id, terminal_id, @@ -2937,35 +2806,6 @@ impl HeadlessServer { } } - fn drain_api_requests_with_render_impact(&mut self) -> RenderImpact { - let mut impact = RenderImpact::None; - while !self.should_quit.load(Ordering::Acquire) { - let Ok(msg) = self.app.api_rx.try_recv() else { - break; - }; - impact.merge(self.handle_api_request_with_render_impact(msg)); - } - impact - } - - fn handle_api_request_with_render_impact( - &mut self, - msg: api::ApiRequestMessage, - ) -> RenderImpact { - if matches!( - &msg.request.method, - api::schema::Method::PaneGraphicsStreamSet(_) - | api::schema::Method::PaneGraphicsStreamDirect(_) - ) { - return self.handle_pane_graphics_stream_frame(msg); - } - if self.handle_api_request_with_shutdown_check(msg) { - RenderImpact::Full - } else { - RenderImpact::None - } - } - fn handle_api_request_with_shutdown_check_inner( &mut self, msg: api::ApiRequestMessage, @@ -2999,12 +2839,6 @@ impl HeadlessServer { }; let metadata_expired = self.app.expire_due_metadata(Instant::now()); - let stream_open = match &msg.request.method { - api::schema::Method::PaneGraphicsStreamOpen(params) => Some(params.clone()), - _ => None, - }; - let stream_active = msg.stream_active.clone(); - if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method { let handoff_result = self.perform_live_handoff(params.clone()); let handoff_succeeded = handoff_result.is_ok(); @@ -3054,16 +2888,7 @@ impl HeadlessServer { _ => {} } - let pane_graphics_revision_before = matches!( - &msg.request.method, - api::schema::Method::PaneGraphicsSet(_) - | api::schema::Method::PaneGraphicsClear(_) - | api::schema::Method::PaneGraphicsStreamOpen(_) - | api::schema::Method::PaneGraphicsStreamClose(_) - ) - .then_some(self.app.pane_graphics.revision()); - let mut changed = metadata_expired - | (pane_graphics_revision_before.is_none() && api::request_changes_ui(&msg.request)); + let mut changed = metadata_expired | api::request_changes_ui(&msg.request); let skip_default_workspace = skip_default_workspace_for_request || matches!( &msg.request.method, @@ -3184,10 +3009,6 @@ impl HeadlessServer { } } } - if let (Some(params), Some(active)) = (stream_open.as_ref(), stream_active) { - self.app - .attach_pane_graphics_stream_active(params, active, &response); - } if let Some(spec) = alt_screen_read_spec { if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) { if let api::schema::ResponseResult::PaneRead { read } = success.result { @@ -3210,10 +3031,6 @@ impl HeadlessServer { } let _ = msg.respond_to.send(response); - if let Some(revision_before) = pane_graphics_revision_before { - changed |= revision_before != self.app.pane_graphics.revision(); - } - // Forward new toast state only when a client-local delivery mode is selected. // Herdr delivery renders the toast in-frame and must not ask clients to // show a terminal or system notification. diff --git a/src/server/headless/endpoint_requests.rs b/src/server/headless/endpoint_requests.rs index afdcc80a..6d2dd6ba 100644 --- a/src/server/headless/endpoint_requests.rs +++ b/src/server/headless/endpoint_requests.rs @@ -127,7 +127,6 @@ impl HeadlessServer { request: *request, respond_to, response_write_complete: None, - stream_active: None, }, ) } diff --git a/src/server/headless/native_graphics.rs b/src/server/headless/native_graphics.rs new file mode 100644 index 00000000..67417d38 --- /dev/null +++ b/src/server/headless/native_graphics.rs @@ -0,0 +1,599 @@ +//! Decoded native exports. Deliberately independent of API slots/leases. +use super::HeadlessServer; +use crate::kitty_graphics::surface::{DeliveryCache, SourceFiles, NATIVE_SLOT_BIT}; +use crate::pane_graphics_files::{FileStore, OwnedExport}; +use crate::protocol::{ + ServerMessage, SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, + SurfaceGraphicsScene, SurfaceGraphicsSource, +}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::kitty_graphics::surface::NATIVE_TRANSFER_BIT as NATIVE_BIT; +const MAX_FILE: usize = 16 * 1024 * 1024; +const MAX_BYTES: usize = 64 * 1024 * 1024; +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(5); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(3); + +pub(super) struct Pending { + export: Arc<OwnedExport>, + transfer_id: u64, + image_id: u32, + source: SurfaceGraphicsSource, + asset: SurfaceGraphicsAssetKey, + slot: bool, + deadline: Instant, + written: bool, + refresh_needed: bool, + scene: SurfaceGraphicsScene, + delivery: DeliveryCache, +} + +struct AcknowledgedSlot { + asset: SurfaceGraphicsAssetKey, + slot: bool, +} + +impl Pending { + pub(super) fn defer_inline_delivery(&mut self, delivery: &DeliveryCache) { + self.delivery = delivery.clone(); + self.refresh_needed = true; + } +} + +pub(super) struct NativeGraphics { + store: FileStore, + pending: HashMap<u64, Pending>, + disabled: HashSet<u64>, + next_transfer: u64, + source_retries: HashMap<u64, Instant>, + // The key and bank currently addressed for each logical source. Native state + // advances on ACK; inline state and source pruning advance on queued scenes. + acknowledged_slots: HashMap<u64, HashMap<SurfaceGraphicsSource, AcknowledgedSlot>>, +} +impl Default for NativeGraphics { + fn default() -> Self { + Self { + store: FileStore::default(), + pending: HashMap::new(), + disabled: HashSet::new(), + next_transfer: 1, + source_retries: HashMap::new(), + acknowledged_slots: HashMap::new(), + } + } +} +impl NativeGraphics { + #[cfg(all(test, unix))] + pub(super) fn busy(&self) -> bool { + !self.pending.is_empty() + } + pub(super) fn is_pending(&self, client: u64) -> bool { + self.pending.contains_key(&client) + } + pub(super) fn can_hold(&self, client: u64, scene: &SurfaceGraphicsScene) -> bool { + let Some(pending) = self.pending.get(&client) else { + return true; + }; + let same_image = |a: &crate::protocol::SurfaceGraphicsAssetKey, + b: &crate::protocol::SurfaceGraphicsAssetKey| { + a.source == b.source + && a.image_width == b.image_width + && a.image_height == b.image_height + && a.format == b.format + && a.data_len == b.data_len + }; + scene.placements.len() == pending.scene.placements.len() + && scene + .placements + .iter() + .zip(&pending.scene.placements) + .all(|(current, held)| { + if !same_image(¤t.asset, &held.asset) { + return false; + } + let mut geometry = current.clone(); + geometry.asset = held.asset.clone(); + geometry == *held + }) + && scene.retained_assets.len() == pending.scene.retained_assets.len() + && scene + .retained_assets + .iter() + .zip(&pending.scene.retained_assets) + .all(|(current, held)| same_image(current, held)) + } + + pub(super) fn hold(&self, client: u64) -> Option<(SurfaceGraphicsScene, DeliveryCache)> { + self.pending + .get(&client) + .map(|p| (p.scene.clone(), p.delivery.clone())) + } + /// Records bank state only after the scene metadata has been queued. + /// + /// `inline_assets` contains only payloads actually queued on the wire; a + /// selected native upload has already been removed and changes bank only + /// after its ACK. A new inline key addresses bank 0, while replaying the + /// exact native key keeps the client's existing key-to-bank mapping. + /// Pruning is committed here as well: a scene rejected by the writer must + /// not mutate bank history for pixels the client is still displaying. + pub(super) fn commit_scene( + &mut self, + client: u64, + scene: &SurfaceGraphicsScene, + inline_assets: &[SurfaceGraphicsAssetKey], + ) { + let present_sources: HashSet<_> = scene + .placements + .iter() + .map(|placement| &placement.asset.source) + .chain(scene.retained_assets.iter().map(|asset| &asset.source)) + .cloned() + .collect(); + let slots = self.acknowledged_slots.entry(client).or_default(); + slots.retain(|source, _| present_sources.contains(source)); + for asset in inline_assets + .iter() + .filter(|asset| matches!(asset.source, SurfaceGraphicsSource::Terminal { .. })) + { + let same_resident_key = slots + .get(&asset.source) + .is_some_and(|resident| resident.asset == *asset); + if !same_resident_key { + slots.insert( + asset.source.clone(), + AcknowledgedSlot { + asset: asset.clone(), + slot: false, + }, + ); + } + } + if slots.is_empty() { + self.acknowledged_slots.remove(&client); + } + } + #[cfg(all(test, unix))] + pub(super) fn prepare( + &mut self, + client: u64, + scope: &str, + scene: &mut SurfaceGraphicsScene, + delivery: &DeliveryCache, + ) -> Option<(Pending, ServerMessage)> { + self.prepare_with_sources(client, scope, scene, delivery, &mut SourceFiles::default()) + } + + pub(super) fn prepare_with_sources( + &mut self, + client: u64, + scope: &str, + scene: &mut SurfaceGraphicsScene, + delivery: &DeliveryCache, + sources: &mut SourceFiles, + ) -> Option<(Pending, ServerMessage)> { + if self.disabled.contains(&client) + || self.pending.contains_key(&client) + || self.pending.len() >= 8 + { + return None; + } + let index = scene + .assets + .iter() + .position(|asset| eligible(asset, sources))?; + let asset = &scene.assets[index]; + if self.pending.values().map(|p| p.export.len()).sum::<usize>() + + asset.key.data_len as usize + > MAX_BYTES + || self.next_transfer >= NATIVE_BIT + { + return None; + } + let export = if let Some(source) = sources.get(&asset.key) { + Arc::clone(source) + } else { + Arc::new(self.store.export(&asset.data).ok()?) + }; + let transfer_id = NATIVE_BIT | self.next_transfer; + self.next_transfer += 1; + let asset_key = asset.key.clone(); + let source = asset_key.source.clone(); + // Start in bank 1: an older client may still be displaying an inline image + // under the unchanged logical base ID. Thereafter always stage into the + // bank opposite the one this client currently addresses. + let slot = !self + .acknowledged_slots + .get(&client) + .and_then(|slots| slots.get(&source)) + .map(|resident| resident.slot) + .unwrap_or(false); + let base = crate::kitty_graphics::surface::native_host_image_id(scope, &asset.key); + let image_id = base ^ if slot { NATIVE_SLOT_BIT } else { 0 }; + let message = ServerMessage::GraphicsFile { + path: export.path().to_string_lossy().into_owned(), + expected_len: export.len() as u64, + image_id, + transfer_id, + leading: Vec::new(), + control: format!( + "a=t,f=32,s={},v={},i={image_id},q=0", + asset.key.image_width, asset.key.image_height + ), + surface_asset: Some(asset_key.clone()), + }; + sources.remove(&asset_key); + scene.assets.remove(index); + let mut held = scene.clone(); + held.assets.clear(); + Some(( + Pending { + export, + transfer_id, + image_id, + source, + asset: asset_key, + slot, + deadline: Instant::now() + DELIVERY_TIMEOUT, + written: false, + refresh_needed: delivery.has_pending(), + scene: held, + delivery: delivery.clone(), + }, + message, + )) + } + pub(super) fn commit(&mut self, client: u64, pending: Pending) { + self.pending.insert(client, pending); + } + fn matches(&self, client: u64, transfer: u64, image: u32) -> bool { + self.pending + .get(&client) + .is_some_and(|p| p.transfer_id == transfer && p.image_id == image) + } +} +fn materialize_sources( + scene: &mut SurfaceGraphicsScene, + delivery: &mut DeliveryCache, + sources: &mut SourceFiles, +) -> bool { + let mut failed = false; + scene.assets.retain_mut(|asset| { + let Some(source) = sources.remove(&asset.key) else { + return true; + }; + match source.copy_rgba() { + Ok(data) => { + asset.data = data; + true + } + Err(error) => { + tracing::debug!(%error, "native source fallback could not be read"); + failed = true; + delivery.forget_asset(&asset.key); + false + } + } + }); + sources.clear(); + failed +} + +fn eligible(asset: &SurfaceGraphicsAsset, sources: &SourceFiles) -> bool { + matches!(asset.key.source, SurfaceGraphicsSource::Terminal { .. }) + && asset.key.format == SurfaceGraphicsFormat::Rgba + && asset.key.data_len > 0 + && asset.key.data_len <= MAX_FILE as u64 + && sources.get(&asset.key).map_or_else( + || asset.key.data_len == asset.data.len() as u64, + |source| asset.key.data_len == source.len() as u64, + ) + && u64::from(asset.key.image_width) + .checked_mul(u64::from(asset.key.image_height)) + .and_then(|pixels| pixels.checked_mul(4)) + == Some(asset.key.data_len) +} +impl HeadlessServer { + pub(super) fn defer_changed_native_geometry( + &mut self, + client: u64, + scene: &SurfaceGraphicsScene, + ) -> bool { + if self.native_graphics.can_hold(client, scene) { + return false; + } + self.retire_native_graphics_for_client(client); + self.app.render_dirty.request_generic(); + if let Some(client) = self.clients.get_mut(&client) { + client.defer_full_render(); + } + true + } + + fn materialize_native_sources( + &mut self, + client: u64, + scene: &mut SurfaceGraphicsScene, + delivery: &mut DeliveryCache, + sources: &mut SourceFiles, + ) { + if materialize_sources(scene, delivery, sources) { + self.native_graphics + .source_retries + .entry(client) + .or_insert_with(|| Instant::now() + std::time::Duration::from_secs(1)); + } else { + self.native_graphics.source_retries.remove(&client); + } + } + + pub(super) fn prepare_native_scene( + &mut self, + client: u64, + scene: &mut SurfaceGraphicsScene, + delivery: &mut DeliveryCache, + sources: &mut SourceFiles, + ) -> Option<(Pending, ServerMessage)> { + if let Some(pending) = self.native_graphics.pending.get_mut(&client) { + pending.refresh_needed |= !scene.assets.is_empty() + || scene.placements != pending.scene.placements + || scene.retained_assets != pending.scene.retained_assets; + } + if let Some((held, held_delivery)) = self.native_graphics.hold(client) { + *scene = held; + *delivery = held_delivery; + sources.clear(); + return None; + } + if !self.client_supports_direct_graphics(client) { + self.materialize_native_sources(client, scene, delivery, sources); + return None; + } + let mut prepared = self.native_graphics.prepare_with_sources( + client, + &self.client_shell_boot_id, + scene, + delivery, + sources, + ); + self.materialize_native_sources(client, scene, delivery, sources); + if let Some((pending, _)) = &mut prepared { + pending.delivery = delivery.clone(); + pending.refresh_needed |= delivery.has_pending(); + } + prepared + } + + pub(super) fn native_started(&mut self, client: u64, transfer: u64, image: u32) -> bool { + if transfer & NATIVE_BIT == 0 { + return false; + } + if !self.native_graphics.disabled.contains(&client) + && self.native_graphics.matches(client, transfer, image) + { + let p = self + .native_graphics + .pending + .get_mut(&client) + .expect("matched"); + p.written = true; + p.deadline = Instant::now() + RESPONSE_TIMEOUT; + } + true + } + pub(super) fn native_result( + &mut self, + client: u64, + transfer: u64, + image: u32, + success: bool, + ) -> Option<bool> { + if transfer & NATIVE_BIT == 0 { + return None; + } + if !self.native_graphics.matches(client, transfer, image) { + return Some(false); + } + if self.native_graphics.disabled.contains(&client) { + return Some(self.expire_native_graphics(Instant::now())); + } + if success { + if !self.native_graphics.pending[&client].written { + return Some(false); + } + let pending = self + .native_graphics + .pending + .remove(&client) + .expect("matched"); + self.native_graphics + .acknowledged_slots + .entry(client) + .or_default() + .insert( + pending.source, + AcknowledgedSlot { + asset: pending.asset, + slot: pending.slot, + }, + ); + let refresh = pending.refresh_needed; + if refresh { + if let Some(c) = self.clients.get_mut(&client) { + c.defer_full_render(); + } + } + return Some(refresh); + } + self.native_graphics.disabled.insert(client); + self.native_graphics + .pending + .get_mut(&client) + .expect("matched") + .deadline = Instant::now(); + Some(self.expire_native_graphics(Instant::now())) + } + pub(super) fn expire_native_graphics(&mut self, now: Instant) -> bool { + let expired: Vec<_> = self + .native_graphics + .pending + .iter() + .filter(|(_, p)| p.deadline <= now) + .map(|(id, p)| (*id, p.transfer_id, p.image_id)) + .collect(); + let mut changed = false; + self.native_graphics.source_retries.retain(|id, deadline| { + if *deadline > now { + return true; + } + if let Some(client) = self.clients.get_mut(id) { + client.defer_full_render(); + changed = true; + } + false + }); + for (id, transfer_id, image_id) in expired { + self.native_graphics.disabled.insert(id); + let message = ServerMessage::GraphicsTransmissionRetired { + transfer_id, + image_id, + }; + // Control messages precede queued renders. The client permanently rejects + // retired native transfers, including a file still in the render queue. + let sent = self.send_to_client(id, message); + if !sent { + continue; + } // Keep the export alive until retirement is queued or disconnect cleans it. + self.native_graphics.pending.remove(&id); + self.native_graphics.disabled.insert(id); + if let Some(c) = self.clients.get_mut(&id) { + c.shell_graphics_delivery = DeliveryCache::default(); + c.defer_full_render(); + } + changed = true; + } + changed + } + pub(super) fn retire_native_graphics_for_client(&mut self, id: u64) { + if let Some(p) = self.native_graphics.pending.get_mut(&id) { + p.deadline = Instant::now(); + self.native_graphics.disabled.insert(id); + if self.expire_native_graphics(Instant::now()) { + self.app.render_dirty.request_generic(); + } + } + } + pub(super) fn disconnect_native_graphics(&mut self, id: u64) { + self.native_graphics.pending.remove(&id); + self.native_graphics.disabled.remove(&id); + self.native_graphics.source_retries.remove(&id); + self.native_graphics.acknowledged_slots.remove(&id); + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::protocol::{SurfaceGraphicsAssetKey, SurfaceGraphicsTarget}; + + fn asset() -> SurfaceGraphicsAsset { + SurfaceGraphicsAsset { + key: SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "native-test".into(), + }, + image_id: 1, + }, + image_width: 1, + image_height: 1, + format: SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint: 123, + }, + data: vec![1, 2, 3, 255], + } + } + + #[test] + fn eligibility_is_decoded_rgba_terminal_only() { + let mut a = asset(); + assert!(eligible(&a, &SourceFiles::default())); + a.key.format = SurfaceGraphicsFormat::Png; + assert!(!eligible(&a, &SourceFiles::default())); + a.key.format = SurfaceGraphicsFormat::Rgba; + a.key.data_len = 5; + assert!(!eligible(&a, &SourceFiles::default())); + a.key.data_len = 4; + a.key.source = SurfaceGraphicsSource::PaneLayer { + pane_id: "p".into(), + layer_id: "l".into(), + }; + assert!(!eligible(&a, &SourceFiles::default())); + let mut a = asset(); + a.data.resize(MAX_FILE + 1, 0); + a.key.data_len = a.data.len() as u64; + assert!(!eligible(&a, &SourceFiles::default())); + } + + #[test] + fn pending_guard_and_client_identity_are_independent() { + let mut state = NativeGraphics::default(); + let mut scene = SurfaceGraphicsScene { + assets: vec![asset()], + ..Default::default() + }; + let (pending, _) = state + .prepare(7, "scope", &mut scene, &DeliveryCache::default()) + .unwrap(); + let path = pending.export.path().to_owned(); + let transfer = pending.transfer_id; + let image = pending.image_id; + assert_ne!(transfer & NATIVE_BIT, 0); + assert!(scene.assets.is_empty()); + assert!(!state.busy()); // prepare is not a delivery commit + state.commit(7, pending); + assert!(state.matches(7, transfer, image)); + assert!(!state.matches(8, transfer, image)); + assert!(!state.matches(7, transfer + 1, image)); + assert!(!state.matches(7, transfer, image + 1)); + assert!(path.exists()); + assert!(state.hold(7).unwrap().0.assets.is_empty()); + state.pending.remove(&7); + assert!(!path.exists()); + state.disabled.insert(7); + scene.assets.push(asset()); + assert!(state + .prepare(7, "scope", &mut scene, &DeliveryCache::default()) + .is_none()); + assert_eq!(scene.assets.len(), 1); // native fallback keeps authoritative inline bytes + } + + #[test] + fn one_pending_per_client_and_global_count_limit() { + let mut state = NativeGraphics::default(); + for id in 0..8 { + let mut scene = SurfaceGraphicsScene { + assets: vec![asset()], + ..Default::default() + }; + let (pending, _) = state + .prepare(id, "scope", &mut scene, &DeliveryCache::default()) + .unwrap(); + state.commit(id, pending); + scene.assets.push(asset()); + assert!(state + .prepare(id, "scope", &mut scene, &DeliveryCache::default()) + .is_none()); + } + let mut scene = SurfaceGraphicsScene { + assets: vec![asset()], + ..Default::default() + }; + assert!(state + .prepare(9, "scope", &mut scene, &DeliveryCache::default()) + .is_none()); + assert_eq!(scene.assets.len(), 1); + } +} diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs deleted file mode 100644 index 947d2cb8..00000000 --- a/src/server/headless/pane_graphics.rs +++ /dev/null @@ -1,513 +0,0 @@ -use super::{HeadlessServer, RenderImpact}; -use crate::api; -use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; -use crate::server::clients::ClientConnectionMode; - -impl HeadlessServer { - pub(super) fn pane_graphics_runtime_active(&self) -> bool { - !self.app.pane_graphics.slots.is_empty() - } - - pub(super) fn handle_pane_graphics_stream_frame( - &mut self, - msg: api::ApiRequestMessage, - ) -> RenderImpact { - if self.shutting_down { - let _ = self.handle_api_request_with_shutdown_check_inner(msg, false, false); - return RenderImpact::None; - } - - let internal_changed = self.drain_all_internal_events_with_forwarding(); - let direct = match &msg.request.method { - api::schema::Method::PaneGraphicsStreamDirect(params) => Some(params.clone()), - _ => None, - }; - let direct_key = direct.as_ref().and_then(|params| { - self.app.parse_pane_id(¶ms.pane_id).map(|(_, pane_id)| { - ( - pane_id, - params - .layer_id - .clone() - .unwrap_or_else(|| api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()), - ) - }) - }); - let direct_client = direct_key - .as_ref() - .and_then(|key| self.direct_graphics_client_for_key(key)); - let gate_busy = self - .app - .pane_graphics - .slots - .values() - .any(|slot| slot.direct_gate.is_some()); - self.app.direct_graphics_available = direct_client.is_some() && !gate_busy; - let response = self - .app - .handle_api_request_after_internal_events_drained(msg.request); - self.app.direct_graphics_available = self.direct_graphics_available(); - let succeeded = serde_json::from_str::<api::schema::SuccessResponse>(&response).is_ok(); - - if succeeded { - if let (Some(params), Some(client_id)) = (direct, direct_client) { - let direct_frame = direct_key.as_ref().and_then(|key| { - self.app.pane_graphics.slots.get(key).and_then(|slot| { - let active_owner = slot.stream_owner.as_deref() == Some(¶ms.owner) - && slot.stream_is_active(); - active_owner.then_some(())?; - slot.layer - .as_ref() - .and_then(crate::app::pane_graphics::Layer::direct_lease) - .map(|lease| { - ( - lease.path().to_string_lossy().into_owned(), - lease.len() as u64, - lease.fingerprint(), - ) - }) - }) - }); - if let (Some(key), Some((path, expected_len, transfer_id))) = - (direct_key.clone(), direct_frame) - { - let prepared = self.clients.get(&client_id).and_then(|client| { - matches!(client.mode, ClientConnectionMode::ClientShell).then_some(())?; - let layer = self - .app - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.layer.as_ref())?; - let asset = crate::kitty_graphics::surface::pane_layer_asset_key( - &self.app, &key, layer, - )?; - let (image_id, control) = - crate::kitty_graphics::surface::direct_upload_control( - &self.client_shell_boot_id, - &asset, - ); - Some(( - crate::kitty_graphics::DirectFileCommand { - leading: Vec::new(), - control, - }, - Some(asset), - image_id, - )) - }); - let Some((command, surface_asset, image_id)) = prepared else { - if self.install_inline_fallback(&key) { - if msg.respond_to.send(response).is_err() { - self.retire_direct_gate(&key); - } - return if internal_changed { - RenderImpact::Full - } else { - RenderImpact::Graphics - }; - } - self.retire_direct_gate(&key); - return if internal_changed { - RenderImpact::Full - } else { - RenderImpact::Graphics - }; - }; - let message = ServerMessage::GraphicsFile { - path, - expected_len, - image_id, - transfer_id, - leading: command.leading, - control: command.control, - surface_asset, - }; - let send = - Self::frame_server_message_with_max(&message, MAX_GRAPHICS_FRAME_SIZE) - .map_err(|_| std::sync::mpsc::TrySendError::Full(Vec::new())) - .and_then(|framed| { - let Some(writer) = self - .clients - .get(&client_id) - .and_then(|client| client.writer.as_ref()) - else { - return Err(std::sync::mpsc::TrySendError::Disconnected( - framed, - )); - }; - writer.render.send_ordered(framed) - }); - match send { - Ok(()) => { - if let Some(slot) = self.app.pane_graphics.slots.get_mut(&key) { - slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { - transfer_id, - image_id, - client_id, - deadline: std::time::Instant::now() - + crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT, - written: false, - success_response: response, - respond_to: msg.respond_to, - }); - } - return if internal_changed { - RenderImpact::Full - } else { - RenderImpact::None - }; - } - Err(error) => { - self.handle_unwritten_direct_failure( - &key, - response, - msg.respond_to, - error, - ); - return RenderImpact::Graphics; - } - } - } - } - } - - let response_failed = msg.respond_to.send(response).is_err(); - if succeeded && response_failed { - if let Some(key) = direct_key { - self.retire_direct_gate(&key); - } - } - if internal_changed { - RenderImpact::Full - } else if succeeded { - RenderImpact::Graphics - } else { - RenderImpact::None - } - } - - fn install_inline_fallback(&mut self, key: &crate::app::pane_graphics::Key) -> bool { - let len = self - .app - .pane_graphics - .slots - .get(key) - .and_then(|slot| slot.layer.as_ref()) - .and_then(crate::app::pane_graphics::Layer::direct_lease) - .map(crate::pane_graphics_files::Lease::len); - let Some(len) = len else { - return false; - }; - if len > crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES - || !self.app.pane_graphics.can_store_inline(key, len) - { - return false; - } - let data = self - .app - .pane_graphics - .slots - .get(key) - .and_then(|slot| slot.layer.as_ref()) - .and_then(crate::app::pane_graphics::Layer::direct_lease) - .and_then(|lease| lease.copy_rgba().ok()); - let Some(data) = data else { - return false; - }; - let slot = self - .app - .pane_graphics - .slots - .get_mut(key) - .expect("matched slot"); - if !slot.stream_is_active() { - return false; - } - let layer = slot.layer.take().expect("direct layer"); - slot.layer = Some(crate::app::pane_graphics::Layer::inline( - layer.format, - layer.image_width, - layer.image_height, - data, - layer.render, - layer.z_index, - )); - self.app.pane_graphics.mark_changed(); - true - } - - pub(super) fn handle_unwritten_direct_failure( - &mut self, - key: &crate::app::pane_graphics::Key, - response: String, - respond_to: std::sync::mpsc::Sender<String>, - error: std::sync::mpsc::TrySendError<Vec<u8>>, - ) -> bool { - if matches!(error, std::sync::mpsc::TrySendError::Full(_)) - && self.install_inline_fallback(key) - && respond_to.send(response).is_ok() - { - return true; - } - self.retire_direct_gate(key); - false - } - - pub(super) fn retire_direct_gate(&mut self, key: &crate::app::pane_graphics::Key) { - if self.app.pane_graphics.slots.remove(key).is_some() { - self.app.pane_graphics.mark_changed(); - } - self.app.direct_graphics_available = self.direct_graphics_available(); - } - - pub(super) fn start_direct_graphics_response( - &mut self, - client_id: u64, - transfer_id: u64, - image_id: u32, - ) -> bool { - if let Some(gate) = self.app.pane_graphics.slots.values_mut().find_map(|slot| { - slot.stream_is_active() - .then_some(slot.direct_gate.as_mut()) - .flatten() - .filter(|gate| { - gate.client_id == client_id - && gate.transfer_id == transfer_id - && gate.image_id == image_id - }) - }) { - gate.written = true; - gate.deadline = - std::time::Instant::now() + crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT; - } - false - } - - pub(super) fn complete_direct_graphics( - &mut self, - client_id: u64, - transfer_id: u64, - image_id: u32, - success: bool, - ) -> bool { - if self.shutting_down { - self.retire_all_direct_graphics(); - return false; - } - let key = self.app.pane_graphics.slots.iter().find_map(|(key, slot)| { - slot.direct_gate - .as_ref() - .is_some_and(|gate| { - slot.stream_is_active() - && gate.client_id == client_id - && gate.transfer_id == transfer_id - && gate.image_id == image_id - && (!success || gate.written) - }) - .then(|| key.clone()) - }); - let Some(key) = key else { - return false; - }; - let pane_is_live = self - .app - .state - .workspaces - .iter() - .any(|workspace| workspace.pane_state(key.0).is_some()); - if !pane_is_live { - self.retire_direct_gate_with_client_notice(&key); - return false; - } - if success { - let gate = { - let slot = self - .app - .pane_graphics - .slots - .get_mut(&key) - .expect("matched slot"); - if !slot.stream_is_active() { - return false; - } - if let Some(layer) = slot.layer.as_mut() { - layer.mark_resident(client_id); - } - slot.direct_gate.take().expect("matched gate") - }; - if gate.respond_to.send(gate.success_response).is_err() { - self.retire_direct_gate(&key); - return true; - } - self.app.direct_graphics_available = self.direct_graphics_available(); - return true; - } - - if let Some(client) = self.clients.get_mut(&client_id) { - client.direct_graphics = false; - } - self.app.direct_graphics_available = false; - if !self.install_inline_fallback(&key) { - self.retire_direct_gate(&key); - self.retire_all_direct_graphics(); - return true; - } - let gate = self - .app - .pane_graphics - .slots - .get_mut(&key) - .filter(|slot| slot.stream_is_active()) - .and_then(|slot| slot.direct_gate.take()); - let Some(gate) = gate else { - self.retire_direct_gate(&key); - return true; - }; - if gate.respond_to.send(gate.success_response).is_err() { - self.retire_direct_gate(&key); - return true; - } - self.retire_all_direct_graphics(); - true - } - - pub(super) fn expire_direct_graphics(&mut self, now: std::time::Instant) -> bool { - let expired = self - .app - .pane_graphics - .slots - .iter() - .filter_map(|(key, slot)| { - slot.direct_gate - .as_ref() - .filter(|gate| gate.deadline <= now) - .map(|gate| (key.clone(), gate.client_id)) - }) - .collect::<Vec<_>>(); - for (key, client_id) in &expired { - if let Some(slot) = self.app.pane_graphics.slots.get(key) { - if let Some(gate) = &slot.direct_gate { - self.send_to_client( - *client_id, - ServerMessage::GraphicsTransmissionRetired { - transfer_id: gate.transfer_id, - image_id: gate.image_id, - }, - ); - } - } - if let Some(client) = self.clients.get_mut(client_id) { - client.direct_graphics = false; - } - self.retire_direct_gate(key); - } - if !expired.is_empty() { - self.app.direct_graphics_available = false; - self.retire_all_direct_graphics(); - } - !expired.is_empty() - } - - fn retire_direct_gate_with_client_notice(&mut self, key: &crate::app::pane_graphics::Key) { - if let Some((client_id, transfer_id, image_id)) = self - .app - .pane_graphics - .slots - .get(key) - .and_then(|slot| slot.direct_gate.as_ref()) - .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)) - { - self.send_to_client( - client_id, - ServerMessage::GraphicsTransmissionRetired { - transfer_id, - image_id, - }, - ); - } - self.retire_direct_gate(key); - } - - pub(super) fn retain_live_pane_graphics(&mut self) -> bool { - let dead_direct = self - .app - .pane_graphics - .slots - .iter() - .filter(|((pane_id, _), slot)| { - slot.direct_gate.is_some() - && !self - .app - .state - .workspaces - .iter() - .any(|workspace| workspace.pane_state(*pane_id).is_some()) - }) - .map(|(key, _)| key.clone()) - .collect::<Vec<_>>(); - let changed = !dead_direct.is_empty(); - for key in dead_direct { - self.retire_direct_gate_with_client_notice(&key); - } - let retained = self.app.pane_graphics.retain_live_panes(&self.app.state); - changed || retained - } - - pub(super) fn retire_all_direct_graphics(&mut self) { - let retired = self - .app - .pane_graphics - .slots - .iter() - .filter(|(_, slot)| { - slot.layer - .as_ref() - .is_some_and(crate::app::pane_graphics::Layer::terminal_only) - }) - .map(|(key, slot)| { - let notification = slot - .direct_gate - .as_ref() - .map(|gate| (gate.client_id, gate.transfer_id, gate.image_id)); - (key.clone(), notification) - }) - .collect::<Vec<_>>(); - for (_, notification) in &retired { - if let Some((client_id, transfer_id, image_id)) = notification { - self.send_to_client( - *client_id, - ServerMessage::GraphicsTransmissionRetired { - transfer_id: *transfer_id, - image_id: *image_id, - }, - ); - } - } - for (key, _) in retired { - self.retire_direct_gate(&key); - } - } - - pub(super) fn retire_direct_graphics_for_client(&mut self, client_id: u64) { - let keys = self - .app - .pane_graphics - .slots - .iter() - .filter_map(|(key, slot)| { - let layer = slot.layer.as_ref()?; - let owned = layer.resident_client() == Some(client_id) - || (layer.direct_lease().is_some() - && slot - .direct_gate - .as_ref() - .is_some_and(|gate| gate.client_id == client_id)); - (layer.terminal_only() && owned).then(|| key.clone()) - }) - .collect::<Vec<_>>(); - for key in keys { - self.retire_direct_gate(&key); - } - } -} diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index a3bbedfa..5bc17c10 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -373,6 +373,15 @@ impl HeadlessServer { } pub(super) fn render_and_stream(&mut self) { + self.render_and_stream_with_graphics_limit(MAX_GRAPHICS_FRAME_SIZE); + } + + #[cfg(all(test, unix))] + pub(super) fn render_and_stream_with_test_graphics_limit(&mut self, max: usize) { + self.render_and_stream_with_graphics_limit(max); + } + + fn render_and_stream_with_graphics_limit(&mut self, graphics_frame_limit: usize) { let full_started = crate::render_prof::timer(); let render_targets = render_targets(&self.clients, self.foreground_client_id); @@ -665,8 +674,16 @@ impl HeadlessServer { popup, graphics, graphics_delivery: next_graphics_delivery, + graphics_sources, } = shell_render.expect("active shell surface"); - surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery)); + surface_parts = Some(( + panes, + splits, + popup, + graphics, + next_graphics_delivery, + graphics_sources, + )); frame } ClientConnectionMode::TerminalPending => continue, @@ -725,6 +742,20 @@ impl HeadlessServer { } }; + if surface_parts + .as_ref() + .is_some_and(|(_, _, _, graphics, _, _)| { + self.defer_changed_native_geometry(client_id, graphics) + }) + { + continue; + } + let mut native_upload = + surface_parts + .as_mut() + .and_then(|(_, _, _, graphics, delivery, sources)| { + self.prepare_native_scene(client_id, graphics, delivery, sources) + }); let Some(client) = self.clients.get_mut(&client_id) else { continue; }; @@ -734,62 +765,102 @@ impl HeadlessServer { }; let has_graphics = surface_parts .as_ref() - .is_some_and(|(_, _, _, graphics, _)| { + .is_some_and(|(_, _, _, graphics, _, _)| { !graphics.assets.is_empty() || !graphics.placements.is_empty() || !graphics.retained_assets.is_empty() }); let mut next_shell_graphics_delivery = None; - let prepared = if let Some((panes, splits, popup, graphics, delivery)) = surface_parts { - next_shell_graphics_delivery = Some(delivery); - client - .render_state - .prepare_pane_surface(protocol::PaneSurfaceFrame { - boot_id: self.client_shell_boot_id.clone(), - projection_revision: shell_projection_revision, - surface_revision: 0, - frame, - panes, - splits, - popup, - graphics, - }) - } else { - client.render_state.prepare_frame(frame) - }; + let prepared = + if let Some((panes, splits, popup, graphics, delivery, _)) = surface_parts { + next_shell_graphics_delivery = Some(delivery); + client.render_state.prepare_pane_surface_with_file( + protocol::PaneSurfaceFrame { + boot_id: self.client_shell_boot_id.clone(), + projection_revision: shell_projection_revision, + surface_revision: 0, + frame, + panes, + splits, + popup, + graphics, + }, + native_upload.is_some(), + ) + } else { + client.render_state.prepare_frame(frame) + }; let Some(mut prepared) = prepared else { client.clear_deferred_render(); crate::render_prof::event("full_render.skip_identical"); continue; }; let max = if has_graphics { - MAX_GRAPHICS_FRAME_SIZE + graphics_frame_limit } else { crate::protocol::MAX_FRAME_SIZE }; let mut shell_assets_deferred = false; - let serialized = match Self::frame_server_message_with_max(prepared.message(), max) { + let mut suppress_impossible_asset_retry = false; + let mut stripped_assets = Vec::new(); + let mut serialized = match Self::frame_server_message_with_max(prepared.message(), max) + { Ok(frame) => frame, Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => { warn!( client_id, - claimed, max, "dropping graphics assets from oversized pane surface" + claimed, max, "trimming inline graphics assets from oversized pane surface" ); - if !prepared.strip_pane_surface_assets() { - crate::render_prof::event("full_render.serialize_oversized"); + let framed = loop { + let Some(key) = prepared.pop_pane_surface_asset() else { + break None; + }; + stripped_assets.push(key); + match Self::frame_server_message_with_max(prepared.message(), max) { + Ok(framed) => break Some(framed), + Err(protocol::FramingError::Oversized { .. }) => {} + Err(err) => { + warn!(client_id, err = %err, "failed to serialize trimmed pane surface"); + broken_clients.push(client_id); + break None; + } + } + }; + let Some(framed) = framed else { + if stripped_assets.is_empty() && prepared.has_queued_surface_assets() { + // Delta/reuse owns an encoded payload that cannot be trimmed in + // place. Drop its baseline so the bounded full-surface path runs next. + client.render_state.request_repaint(); + client.defer_full_render(); + } else { + crate::render_prof::event("full_render.serialize_oversized"); + } continue; - } - next_shell_graphics_delivery = None; - shell_assets_deferred = true; - match Self::frame_server_message(prepared.message()) { - Ok(framed) => framed, - Err(err) => { - warn!(client_id, err = %err, "failed to serialize pane surface without assets"); - broken_clients.push(client_id); - crate::render_prof::event("full_render.serialize_error"); - continue; + }; + let made_progress = + native_upload.is_some() || prepared.has_queued_surface_assets(); + if let Some(delivery) = next_shell_graphics_delivery.as_mut() { + for key in &stripped_assets { + delivery.forget_asset(key); } + shell_assets_deferred = made_progress && !stripped_assets.is_empty(); + } + if let (Some((pending, _)), Some(delivery)) = ( + native_upload.as_mut(), + next_shell_graphics_delivery.as_ref(), + ) { + pending.defer_inline_delivery(delivery); } + if !made_progress { + // Keep the asset absent from the delivery cache, but do not spin on + // an identical frame when no payload can fit beside its metadata. + suppress_impossible_asset_retry = true; + warn!( + client_id, + "inline graphics asset cannot fit in a graphics frame; waiting for a later scene change" + ); + } + framed } Err(protocol::FramingError::Oversized { claimed, max }) => { warn!( @@ -806,11 +877,33 @@ impl HeadlessServer { continue; } }; - let shell_graphics_pending = next_shell_graphics_delivery - .as_ref() - .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); - match writer.render.try_send(serialized) { + let shell_graphics_pending = !suppress_impossible_asset_retry + && next_shell_graphics_delivery + .as_ref() + .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); + if let Some((_, message)) = &native_upload { + let Ok(file_frame) = + Self::frame_server_message_with_max(message, MAX_GRAPHICS_FRAME_SIZE) + else { + client.defer_full_render(); + continue; + }; + serialized.extend_from_slice(&file_frame); + } + let send = if native_upload.is_some() || self.native_graphics.is_pending(client_id) { + writer.render.send_ordered(serialized) + } else { + writer.render.try_send(serialized) + }; + match send { Ok(()) => { + if let Some((graphics, inline_assets)) = prepared.queued_surface_graphics() { + self.native_graphics + .commit_scene(client_id, graphics, inline_assets); + } + if let Some((pending, _)) = native_upload { + self.native_graphics.commit(client_id, pending); + } if let Some(delivery) = next_shell_graphics_delivery { client.shell_graphics_delivery = delivery; } diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs index 07d33ef5..bf98bc3e 100644 --- a/src/server/headless/retained_surface.rs +++ b/src/server/headless/retained_surface.rs @@ -203,6 +203,7 @@ struct RetainedRecipientUpdate { graphics: Option<( protocol::PaneSurfaceFrame, crate::kitty_graphics::surface::DeliveryCache, + crate::kitty_graphics::surface::SourceFiles, )>, } @@ -446,7 +447,7 @@ impl HeadlessServer { let client = &self.clients[&client_id]; let mut next_surface = surface.clone(); crate::server::render_stream::apply_pane_surface_patch(&mut next_surface, &patch); - let Some((graphics, delivery)) = + let Some((graphics, delivery, sources)) = crate::server::client_shell_graphics::collect_retained( &self.app, &next_surface, @@ -460,7 +461,7 @@ impl HeadlessServer { }; graphics_changed = graphics != surface.graphics; next_surface.graphics = graphics; - Some((next_surface, delivery)) + Some((next_surface, delivery, sources)) } else { None }; @@ -490,8 +491,17 @@ impl HeadlessServer { let RetainedRecipientUpdate { client_id, patch, - graphics, + mut graphics, } = update; + if graphics.as_ref().is_some_and(|(surface, _, _)| { + self.defer_changed_native_geometry(client_id, &surface.graphics) + }) { + deferred += 1; + continue; + } + let native_upload = graphics.as_mut().and_then(|(surface, delivery, sources)| { + self.prepare_native_scene(client_id, &mut surface.graphics, delivery, sources) + }); let Some(client) = self.clients.get_mut(&client_id) else { continue; }; @@ -502,9 +512,11 @@ impl HeadlessServer { }; // The published row patch cannot carry images. Reuse the retained text/layout // in a graphics-capable surface message rather than invoking the full renderer. - let (prepared, graphics_delivery) = if let Some((surface, delivery)) = graphics { + let (prepared, graphics_delivery) = if let Some((surface, delivery, _)) = graphics { ( - client.render_state.prepare_pane_surface(surface), + client + .render_state + .prepare_pane_surface_with_file(surface, native_upload.is_some()), Some(delivery), ) } else { @@ -520,7 +532,7 @@ impl HeadlessServer { } else { protocol::MAX_FRAME_SIZE }; - let serialized = + let mut serialized = match Self::frame_server_message_with_max(prepared.message(), max_frame_size) { Ok(serialized) => serialized, Err(error) => { @@ -529,14 +541,39 @@ impl HeadlessServer { %error, "failed to serialize retained pane surface patch" ); + // A delta may own an encoded graphics payload that cannot be + // trimmed in place. Force the bounded full-surface recovery path. + client.render_state.request_repaint(); client.defer_full_render(); deferred += 1; continue; } }; + if let Some((_, message)) = &native_upload { + let Ok(file_frame) = + Self::frame_server_message_with_max(message, MAX_GRAPHICS_FRAME_SIZE) + else { + client.defer_full_render(); + deferred += 1; + continue; + }; + serialized.extend_from_slice(&file_frame); + } crate::render_prof::counter("retained_surface.bytes", serialized.len() as u64); - match writer.render.try_send(serialized) { + let send = if native_upload.is_some() || self.native_graphics.is_pending(client_id) { + writer.render.send_ordered(serialized) + } else { + writer.render.try_send(serialized) + }; + match send { Ok(()) => { + if let Some((graphics, inline_assets)) = prepared.queued_surface_graphics() { + self.native_graphics + .commit_scene(client_id, graphics, inline_assets); + } + if let Some((pending, _)) = native_upload { + self.native_graphics.commit(client_id, pending); + } let graphics_pending = graphics_delivery .as_ref() .is_some_and(crate::kitty_graphics::surface::DeliveryCache::has_pending); diff --git a/src/server/headless/surface_interest.rs b/src/server/headless/surface_interest.rs index 9772fc03..44a68b04 100644 --- a/src/server/headless/surface_interest.rs +++ b/src/server/headless/surface_interest.rs @@ -57,7 +57,7 @@ impl HeadlessServer { if let Some(held_inputs) = held_inputs { self.release_client_shell_inputs(client_id, held_inputs); - self.retire_direct_graphics_for_client(client_id); + self.retire_native_graphics_for_client(client_id); } if changed { self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 84031ce4..34258dd3 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1,9 +1,10 @@ use super::*; -#[path = "pane_graphics.rs"] -mod pane_graphics_tests; +mod native_graphics; #[path = "pane_move.rs"] mod pane_move_tests; +#[path = "pane_graphics.rs"] +mod retained_graphics_tests; #[path = "surface_delta.rs"] mod surface_delta_tests; #[path = "surface_interest.rs"] @@ -102,6 +103,7 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ client_socket_path: socket_path, client_socket_identity, clients: HashMap::new(), + native_graphics: Default::default(), #[cfg(unix)] next_client_id: 1, foreground_client_id: None, @@ -245,7 +247,6 @@ fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> }, respond_to, response_write_complete: None, - stream_active: None, }); let response: api::schema::SuccessResponse = serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); @@ -303,7 +304,6 @@ fn headless_api_request_drains_all_pending_internal_events_before_reading_state( }, respond_to, response_write_complete: None, - stream_active: None, }) ); let response = response_rx @@ -2100,7 +2100,6 @@ async fn client_local_navigation_does_not_emit_global_focus_transitions() { }, respond_to, response_write_complete: None, - stream_active: None, }, ); server.app.sync_focus_events(); @@ -2191,7 +2190,6 @@ async fn client_local_navigation_emits_pane_focused_only_when_that_client_moves( }, respond_to, response_write_complete: None, - stream_active: None, }, ); let response = response_rx.recv().expect("navigation response"); @@ -2323,7 +2321,6 @@ async fn repeated_layout_action_reapplies_controller_geometry() { }, respond_to, response_write_complete: None, - stream_active: None, }, )); @@ -2368,7 +2365,6 @@ async fn public_close_reapplies_controller_geometry() { }, respond_to, response_write_complete: None, - stream_active: None, }) ); @@ -2572,7 +2568,6 @@ async fn public_background_tab_create_preserves_client_locations() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert_eq!( @@ -2620,7 +2615,6 @@ async fn public_workspace_focus_preserves_each_clients_remembered_tabs() { }, respond_to, response_write_complete: None, - stream_active: None, }); let first_location = server.clients[&41].shell_location.as_ref().unwrap(); @@ -2701,7 +2695,6 @@ async fn public_agent_focus_replaces_a_diverged_client_shell_projection() { }, respond_to, response_write_complete: None, - stream_active: None, }); let response: crate::api::schema::SuccessResponse = serde_json::from_str(&response_rx.recv().expect("agent focus response")).unwrap(); @@ -2776,7 +2769,6 @@ async fn public_api_focus_replaces_every_client_shell_projection() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert_eq!(server.app.state.active, Some(1)); server.render_and_stream(); @@ -3393,7 +3385,6 @@ async fn worktree_discovery_does_not_block_client_typing() { }, respond_to, response_write_complete: None, - stream_active: None, }); entered .recv_timeout(Duration::from_secs(5)) @@ -6997,7 +6988,6 @@ fn notification_show_api_forwards_one_semantic_client_notification() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7060,7 +7050,6 @@ fn notification_show_api_preserves_colon_in_forwarded_title() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7106,7 +7095,6 @@ fn notification_show_api_validates_empty_title_before_disabled_delivery() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7137,7 +7125,6 @@ fn notification_show_api_reports_no_foreground_client() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7188,7 +7175,6 @@ fn notification_show_api_includes_sound_in_semantic_event() { }, respond_to, response_write_complete: None, - stream_active: None, }) ); @@ -7250,7 +7236,6 @@ fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema: }, respond_to, response_write_complete: None, - stream_active: None, }); let response = response_rx .recv_timeout(Duration::from_millis(100)) @@ -7585,7 +7570,6 @@ fn stale_api_agent_report_does_not_forward_done_sound() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); diff --git a/src/server/headless/tests/native_graphics.rs b/src/server/headless/tests/native_graphics.rs new file mode 100644 index 00000000..bde7cf08 --- /dev/null +++ b/src/server/headless/tests/native_graphics.rs @@ -0,0 +1,1411 @@ +//! Native export integration tests for file delivery, retirement, and fallback. +#![cfg(unix)] + +use super::*; +use crate::kitty_graphics::surface::{DeliveryCache, NATIVE_SLOT_BIT, NATIVE_TRANSFER_BIT}; +use crate::protocol::{ + SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, SurfaceGraphicsPlacement, + SurfaceGraphicsScene, SurfaceGraphicsSource, SurfaceGraphicsTarget, +}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +fn native_started(server: &mut HeadlessServer, client: u64, transfer: u64, image: u32) -> bool { + server.native_started(client, transfer, image) +} + +fn native_result( + server: &mut HeadlessServer, + client: u64, + transfer: u64, + image: u32, + success: bool, +) -> bool { + server + .native_result(client, transfer, image, success) + .unwrap_or(false) +} + +fn commit_scene(server: &mut HeadlessServer, client: u64, scene: &SurfaceGraphicsScene) { + let inline_assets = scene + .assets + .iter() + .map(|asset| asset.key.clone()) + .collect::<Vec<_>>(); + server + .native_graphics + .commit_scene(client, scene, &inline_assets); +} + +fn scene() -> SurfaceGraphicsScene { + scene_for(1, 123) +} + +fn scene_for(image_id: u32, data_fingerprint: u64) -> SurfaceGraphicsScene { + let key = SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "native-test".into(), + }, + image_id, + }, + image_width: 1, + image_height: 1, + format: SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint, + }; + SurfaceGraphicsScene { + assets: vec![SurfaceGraphicsAsset { + key: key.clone(), + data: vec![1, 2, 3, 255], + }], + placements: vec![SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: 1, + x: 2, + y: 3, + cols: 1, + rows: 1, + source_x: 0, + source_y: 0, + source_width: 1, + source_height: 1, + x_offset: 0, + y_offset: 0, + z: 0, + scrollback_offset: 0, + }], + retained_assets: Vec::new(), + } +} + +fn add_client(server: &mut HeadlessServer, id: u64) -> (Receiver<Vec<u8>>, Receiver<Vec<u8>>) { + let (writer, control, render) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + id, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.mode = ClientConnectionMode::ClientShell; + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(id, client); + server.foreground_client_id = Some(id); + (control, render) +} + +fn prepare_and_commit(server: &mut HeadlessServer, id: u64) -> (PathBuf, u64, u32) { + let mut desired = scene(); + let (pending, message) = server + .native_graphics + .prepare( + id, + &server.client_shell_boot_id, + &mut desired, + &DeliveryCache::default(), + ) + .expect("eligible native export"); + let ServerMessage::GraphicsFile { + path, + transfer_id, + image_id, + expected_len, + leading, + control, + surface_asset, + } = message + else { + panic!("expected file upload"); + }; + assert_ne!(transfer_id & NATIVE_TRANSFER_BIT, 0); + assert_eq!(expected_len, 4); + assert!(leading.is_empty()); + assert_eq!(control, format!("a=t,f=32,s=1,v=1,i={image_id},q=0")); + assert_eq!(surface_asset.as_ref(), Some(&desired.placements[0].asset)); + assert!(desired.assets.is_empty()); + assert_eq!(std::fs::read(&path).unwrap(), [1, 2, 3, 255]); + commit_scene(server, id, &desired); + server.native_graphics.commit(id, pending); + (PathBuf::from(path), transfer_id, image_id) +} + +fn prepare_scene_and_commit( + server: &mut HeadlessServer, + id: u64, + mut desired: SurfaceGraphicsScene, +) -> (u64, u32) { + let (pending, message) = server + .native_graphics + .prepare( + id, + &server.client_shell_boot_id, + &mut desired, + &DeliveryCache::default(), + ) + .expect("eligible native export"); + let ServerMessage::GraphicsFile { + transfer_id, + image_id, + .. + } = message + else { + panic!("expected file upload"); + }; + commit_scene(server, id, &desired); + server.native_graphics.commit(id, pending); + (transfer_id, image_id) +} + +fn ack_native(server: &mut HeadlessServer, id: u64, transfer: u64, image: u32) { + assert!(native_started(server, id, transfer, image)); + native_result(server, id, transfer, image, true); + assert!(!server.native_graphics.is_pending(id)); +} + +fn assert_retirement(control: &Receiver<Vec<u8>>, transfer: u64, image: u32) { + assert!(matches!( + read_server_message(control.recv_timeout(Duration::from_millis(100)).expect("control retirement")), + ServerMessage::GraphicsTransmissionRetired { transfer_id, image_id } + if transfer_id == transfer && image_id == image + )); +} + +fn assert_native_disabled(server: &mut HeadlessServer, id: u64) { + let mut desired = scene(); + assert!(server + .native_graphics + .prepare( + id, + &server.client_shell_boot_id, + &mut desired, + &DeliveryCache::default(), + ) + .is_none()); + assert_eq!(desired.assets[0].data, [1, 2, 3, 255]); + assert!(server.clients[&id].direct_graphics); +} + +#[test] +fn native_tokens_require_started_and_old_api_tokens_are_ignored() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (path, token, image) = prepare_and_commit(&mut server, 7); + + assert!(!native_result(&mut server, 7, token, image, true)); + assert!(server.native_graphics.is_pending(7)); + assert!(path.exists()); + assert!(!native_started( + &mut server, + 7, + token & !NATIVE_TRANSFER_BIT, + image + )); + assert!(!native_result( + &mut server, + 7, + token & !NATIVE_TRANSFER_BIT, + image, + true, + )); + assert!(native_started(&mut server, 7, token, image)); + assert!(!native_result(&mut server, 7, token, image, true)); + assert!(!server.native_graphics.is_pending(7)); + assert!(!path.exists()); +} + +#[test] +fn native_image_banks_advance_only_on_started_ack_and_are_scoped_and_pruned() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (_other_control, _other_render) = add_client(&mut server, 8); + let base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &scene().placements[0].asset, + ); + + // Unknown sources start in bank 1, avoiding an inline image at the base ID. + let (first_transfer, first_image) = prepare_scene_and_commit(&mut server, 7, scene()); + assert_eq!(first_image, base ^ NATIVE_SLOT_BIT); + // A success before Started is not an ACK and neither completes nor advances. + assert!(!native_result( + &mut server, + 7, + first_transfer, + first_image, + true + )); + assert!(server.native_graphics.is_pending(7)); + ack_native(&mut server, 7, first_transfer, first_image); + + let (second_transfer, second_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 124)); + assert_eq!(second_image, base); + // A late ACK for the preceding transfer cannot advance the pending revision. + assert!(!native_result( + &mut server, + 7, + first_transfer, + first_image, + true + )); + assert!(server.native_graphics.is_pending(7)); + ack_native(&mut server, 7, second_transfer, second_image); + let (third_transfer, third_image) = prepare_scene_and_commit(&mut server, 7, scene_for(1, 125)); + assert_eq!(third_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, third_transfer, third_image); + + // Selection is independent per client. + let (other_transfer, other_image) = prepare_scene_and_commit(&mut server, 8, scene_for(1, 126)); + assert_eq!(other_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 8, other_transfer, other_image); + + // A different logical source also starts in bank 1. Retention keeps source + // 1's bank history even though it has no placement in this scene. + let mut source_two = scene_for(2, 127); + source_two + .retained_assets + .push(scene_for(1, 127).placements[0].asset.clone()); + let source_two_base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &source_two.placements[0].asset, + ); + let (source_two_transfer, source_two_image) = + prepare_scene_and_commit(&mut server, 7, source_two); + assert_eq!(source_two_image, source_two_base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, source_two_transfer, source_two_image); + let (retained_transfer, retained_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 128)); + assert_eq!(retained_image, base); + ack_native(&mut server, 7, retained_transfer, retained_image); + + // Move source 1 back to bank 1, then omit it entirely. Returning after an + // unrelated upload starts at bank 1 rather than retaining stale history. + let (before_prune_transfer, before_prune_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 129)); + assert_eq!(before_prune_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, before_prune_transfer, before_prune_image); + let (pruning_transfer, pruning_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(2, 130)); + assert_eq!(pruning_image, source_two_base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, pruning_transfer, pruning_image); + let (restored_transfer, restored_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 131)); + assert_eq!(restored_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, restored_transfer, restored_image); + + // Reusing a disconnected client ID must not inherit its source-bank history. + server.remove_client(7); + let (_control, _render) = add_client(&mut server, 7); + let (_reconnected_transfer, reconnected_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 132)); + assert_eq!(reconnected_image, base ^ NATIVE_SLOT_BIT); +} + +#[test] +fn inline_commit_updates_bank_only_for_a_new_asset_revision() { + // Replaying inline bytes for the exact ACKed key still addresses the native + // bank retained by the client, so it must not be recorded as bank 0. + let mut exact = test_headless_server(); + let (_control, _render) = add_client(&mut exact, 7); + let base = crate::kitty_graphics::surface::native_host_image_id( + &exact.client_shell_boot_id, + &scene().placements[0].asset, + ); + let (first_transfer, first_image) = prepare_scene_and_commit(&mut exact, 7, scene()); + assert_eq!(first_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut exact, 7, first_transfer, first_image); + commit_scene(&mut exact, 7, &scene()); + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut exact, 7, scene_for(1, 124)); + assert_eq!(next_image, base); + + // A new inline revision has no native key mapping on the client and is + // uploaded at the logical base ID. The next native revision must therefore + // stage into bank 1 rather than collide with that visible base image. + let mut revised = test_headless_server(); + let (_control, _render) = add_client(&mut revised, 7); + let (first_transfer, first_image) = prepare_scene_and_commit(&mut revised, 7, scene()); + ack_native(&mut revised, 7, first_transfer, first_image); + commit_scene(&mut revised, 7, &scene_for(1, 124)); + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut revised, 7, scene_for(1, 125)); + assert_eq!(next_image, base ^ NATIVE_SLOT_BIT); +} + +#[test] +fn rejected_omitted_scene_does_not_prune_visible_bank_history() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &scene().placements[0].asset, + ); + let (first_transfer, first_image) = prepare_scene_and_commit(&mut server, 7, scene()); + assert_eq!(first_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, first_transfer, first_image); + + // Preparing an omitted-source scene is speculative. Model the full render + // path rejecting it because its render lane is already occupied. + let writer = ClientWriter::test_paused(); + server.clients.get_mut(&7).unwrap().writer = Some(writer.clone()); + writer.render.try_send(vec![1]).unwrap(); + let mut omitted = SurfaceGraphicsScene::default(); + assert!(server + .prepare_native_scene( + 7, + &mut omitted, + &mut DeliveryCache::default(), + &mut Default::default(), + ) + .is_none()); + assert!(matches!( + writer.render.try_send(vec![2]), + Err(std::sync::mpsc::TrySendError::Full(_)) + )); + + // The rejected omission did not change what is visible. Stage opposite the + // still-visible bank 1 when the source returns. + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut server, 7, scene_for(1, 124)); + assert_eq!(next_image, base); +} + +#[test] +fn unqueued_native_prepare_does_not_advance_image_bank() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &scene_for(3, 200).placements[0].asset, + ); + let mut dropped_scene = scene_for(3, 200); + let (dropped, dropped_message) = server + .native_graphics + .prepare( + 7, + &server.client_shell_boot_id, + &mut dropped_scene, + &DeliveryCache::default(), + ) + .unwrap(); + let ServerMessage::GraphicsFile { + transfer_id: dropped_transfer, + image_id: dropped_image, + .. + } = dropped_message + else { + panic!("expected file upload"); + }; + assert_eq!(dropped_image, base ^ NATIVE_SLOT_BIT); + drop(dropped); // Model serialization/send failure before commit. + + let (transfer, image) = prepare_scene_and_commit(&mut server, 7, scene_for(3, 201)); + assert_eq!(image, dropped_image); + assert!(!native_result( + &mut server, + 7, + dropped_transfer, + dropped_image, + true + )); + assert!(server.native_graphics.is_pending(7)); + ack_native(&mut server, 7, transfer, image); + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut server, 7, scene_for(3, 202)); + assert_eq!(next_image, base); +} + +#[test] +fn native_timeout_uses_control_lane_and_falls_back_inline() { + let mut server = test_headless_server(); + let (control, render) = add_client(&mut server, 7); + let (path, token, image) = prepare_and_commit(&mut server, 7); + // Occupy the render lane: retirement must not wait for file/render backpressure. + server.clients[&7] + .writer + .as_ref() + .unwrap() + .render + .send_ordered(vec![99]) + .unwrap(); + assert!(server.expire_native_graphics(Instant::now() + Duration::from_secs(60))); + assert_retirement(&control, token, image); + assert_eq!( + render.recv_timeout(Duration::from_millis(100)).unwrap(), + vec![99] + ); + assert!(render.try_recv().is_err()); + assert!(!path.exists()); + assert!(!server.native_graphics.is_pending(7)); + assert!(!server.clients[&7].shell_graphics_delivery.has_pending()); + assert_eq!(server.clients[&7].deferred_render(), DeferredRender::Full); + assert_native_disabled(&mut server, 7); + assert!(!native_result(&mut server, 7, token, image, true)); +} + +#[test] +fn native_rejection_falls_back_without_disabling_other_client() { + let mut server = test_headless_server(); + let (control, render) = add_client(&mut server, 7); + let (_other_control, _other_render) = add_client(&mut server, 8); + let (path, token, image) = prepare_and_commit(&mut server, 7); + let (other_path, other_token, other_image) = prepare_and_commit(&mut server, 8); + assert!(native_result(&mut server, 7, token, image, false)); + assert_retirement(&control, token, image); + assert!(render.try_recv().is_err()); + assert!(!path.exists()); + assert_native_disabled(&mut server, 7); + assert!(other_path.exists()); + assert!(server.native_graphics.is_pending(8)); + native_started(&mut server, 8, other_token, other_image); + native_result(&mut server, 8, other_token, other_image, true); + assert!(!other_path.exists()); + assert!(!server.native_graphics.is_pending(8)); + assert!(server.clients[&8].direct_graphics); +} + +#[test] +fn disconnect_unlinks_only_that_clients_native_export() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (_other_control, _other_render) = add_client(&mut server, 8); + let (path, token, image) = prepare_and_commit(&mut server, 7); + let (other_path, other_token, other_image) = prepare_and_commit(&mut server, 8); + server.remove_client(7); + assert!(!path.exists()); + assert!(other_path.exists()); + assert!(!server.native_graphics.is_pending(7)); + assert!(server.native_graphics.is_pending(8)); + assert!(!native_result(&mut server, 7, token, image, true)); + native_started(&mut server, 8, other_token, other_image); + native_result(&mut server, 8, other_token, other_image, true); + assert!(!other_path.exists()); + assert!(!server.native_graphics.is_pending(8)); + assert!(server.clients[&8].direct_graphics); +} + +#[test] +fn pending_hold_accepts_pixel_revision_but_not_geometry_or_source_change() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (_path, _token, _image) = prepare_and_commit(&mut server, 7); + let original = scene(); + assert!(server.native_graphics.can_hold(7, &original)); + let mut next = original.clone(); + next.placements[0].asset.data_fingerprint += 1; + next.assets[0].key.data_fingerprint += 1; + next.assets[0].data[0] += 1; + assert!(server.native_graphics.can_hold(7, &next)); + assert_eq!( + server.native_graphics.hold(7).unwrap().0.placements, + original.placements + ); + next.placements[0].cols += 1; + assert!(!server.native_graphics.can_hold(7, &next)); + next = original.clone(); + next.placements[0].x += 1; + assert!(!server.native_graphics.can_hold(7, &next)); + next = original.clone(); + next.placements[0].asset.image_width += 1; + assert!(!server.native_graphics.can_hold(7, &next)); + next = original.clone(); + next.placements[0].asset.source = SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "another-pane".into(), + }, + image_id: 1, + }; + assert!(!server.native_graphics.can_hold(7, &next)); + assert!(!server + .native_graphics + .can_hold(7, &SurfaceGraphicsScene::default())); + next = original; + next.retained_assets.push(next.placements[0].asset.clone()); + assert!(!server.native_graphics.can_hold(7, &next)); +} + +fn drain_native_render_messages(writer: &ClientWriter) -> Vec<ServerMessage> { + let mut messages = Vec::new(); + for batch in writer.test_drain() { + let len = batch.len() as u64; + let mut cursor = std::io::Cursor::new(batch); + while cursor.position() < len { + messages.push( + protocol::read_message(&mut cursor, protocol::MAX_GRAPHICS_FRAME_SIZE) + .expect("decode ordered scene/file transaction"), + ); + } + } + messages +} + +fn write_chunked_test_image( + server: &mut HeadlessServer, + pane: crate::layout::PaneId, + image_id: u32, + side: u32, + pixels: &[u8], +) { + use base64::Engine as _; + + let encoded = base64::engine::general_purpose::STANDARD.encode(pixels); + let chunks = encoded.as_bytes().chunks(4096); + let count = chunks.len(); + for (index, chunk) in chunks.enumerate() { + let more = u8::from(index + 1 < count); + let mut command = if index == 0 { + format!( + "\x1b_Ga=T,f=32,t=d,i={image_id},p={image_id},s={side},v={side},c=1,r=1,q=2,m={more};" + ) + .into_bytes() + } else { + format!("\x1b_Gm={more};").into_bytes() + }; + command.extend_from_slice(chunk); + command.extend_from_slice(b"\x1b\\"); + write_shared_test_pane(server, pane, &command); + } +} + +#[tokio::test] +async fn delta_encoded_inline_commit_updates_next_native_bank() { + let (mut server, _control, _render, pane) = retained_test_server_with_control( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.render_state.enable_surface_delta(true); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = true; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + + server.render_and_stream(); + let first = drain_native_render_messages(&writer); + let (first_transfer, first_image) = first + .iter() + .find_map(|message| match message { + ServerMessage::GraphicsFile { + transfer_id, + image_id, + .. + } => Some((*transfer_id, *image_id)), + _ => None, + }) + .expect("first native upload"); + assert!(native_started(&mut server, 1, first_transfer, first_image)); + native_result(&mut server, 1, first_transfer, first_image, true); + + // Model a client without direct-file capability for this replacement. + // Delta encoding + // wraps its surface in EndpointControl, but the queued asset still commits + // bank 0 bookkeeping. + server.clients.get_mut(&1).unwrap().direct_graphics = false; + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;AP8A/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane]))); + let inline = drain_native_render_messages(&writer); + assert!(inline.iter().any(|message| matches!( + message, + ServerMessage::EndpointControl { kind, .. } + if kind == protocol::surface_delta::MESSAGE_KIND + ))); + assert!(!inline + .iter() + .any(|message| matches!(message, ServerMessage::GraphicsFile { .. }))); + + server.clients.get_mut(&1).unwrap().direct_graphics = true; + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;AAD//w==\x1b\\", + ); + server.render_and_stream(); + let next = drain_native_render_messages(&writer); + assert!(next.iter().any(|message| matches!( + message, + ServerMessage::EndpointControl { kind, .. } + if kind == protocol::surface_delta::MESSAGE_KIND + ))); + let next_image = next + .iter() + .find_map(|message| match message { + ServerMessage::GraphicsFile { image_id, .. } => Some(*image_id), + _ => None, + }) + .expect("native upload after inline delta"); + assert_eq!(next_image, first_image, "must stage opposite inline bank 0"); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn oversized_native_scene_queues_file_and_continues_stripped_assets_after_ack() { + let (mut server, _control, _render, pane) = retained_test_server_with_control(b""); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = true; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + + const SIDE: u32 = 128; + let pixels = vec![0x7f; SIDE as usize * SIDE as usize * 4]; + for image_id in [7, 8, 9] { + write_chunked_test_image(&mut server, pane, image_id, SIDE, &pixels); + } + + // The limit is deliberately above one 64 KiB asset plus metadata but below + // the two inline assets left after selecting one native upload. + server.render_and_stream_with_test_graphics_limit(100_000); + let first = drain_native_render_messages(&writer); + let first_surface = first + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("trimmed metadata scene"); + assert_eq!(first_surface.graphics.placements.len(), 3); + assert_eq!(first_surface.graphics.assets.len(), 1); + let first_inline = first_surface.graphics.assets[0].key.clone(); + let (transfer, image, first_native) = first + .iter() + .find_map(|message| match message { + ServerMessage::GraphicsFile { + transfer_id, + image_id, + surface_asset: Some(asset), + .. + } => Some((*transfer_id, *image_id, asset.clone())), + _ => None, + }) + .expect("selected native asset survives oversized recovery"); + assert_ne!(first_inline.source, first_native.source); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + assert!(native_started(&mut server, 1, transfer, image)); + native_result(&mut server, 1, transfer, image, true); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + server.render_and_stream_with_test_graphics_limit(100_000); + let continuation = drain_native_render_messages(&writer); + let continued_asset = continuation.iter().find_map(|message| match message { + ServerMessage::GraphicsFile { + surface_asset: Some(asset), + .. + } => Some(asset), + ServerMessage::PaneSurface(surface) => { + surface.graphics.assets.first().map(|asset| &asset.key) + } + _ => None, + }); + let continued_asset = continued_asset.expect("stripped asset delivered on continuation"); + assert_ne!(continued_asset.source, first_inline.source); + assert_ne!(continued_asset.source, first_native.source); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn oversized_inline_trims_largest_and_sends_fitting_asset_without_retry_spin() { + let (mut server, _control, _render, pane) = retained_test_server_with_control(b""); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = false; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + + for (image_id, side) in [(7, 128u32), (8, 16u32)] { + let pixels = vec![image_id as u8; side as usize * side as usize * 4]; + write_chunked_test_image(&mut server, pane, image_id, side, &pixels); + } + + // Probe the exact payload size of this surface with only the small asset. + // Resetting the server-side delivery/baseline makes the measured render + // observational only; the next call exercises the production recovery path. + server.render_and_stream(); + let mut measured_surface = drain_native_render_messages(&writer) + .into_iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("measurement surface"); + assert_eq!(measured_surface.graphics.assets.len(), 2); + measured_surface + .graphics + .assets + .retain(|asset| asset.key.image_width == 16); + let exact_small_limit = HeadlessServer::frame_server_message_with_max( + &ServerMessage::PaneSurface(measured_surface), + crate::protocol::MAX_GRAPHICS_FRAME_SIZE, + ) + .unwrap() + .len() + .saturating_sub(4); + let client = server.clients.get_mut(&1).unwrap(); + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.shell_graphics_delivery = DeliveryCache::default(); + client.clear_deferred_render(); + + // Exactly metadata plus the 1 KiB image fits; adding the 64 KiB image does not. + server.render_and_stream_with_test_graphics_limit(exact_small_limit); + let first = drain_native_render_messages(&writer); + let first_surface = first + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("surface with fitting inline asset"); + assert_eq!(first_surface.graphics.assets.len(), 1); + assert_eq!(first_surface.graphics.assets[0].key.image_width, 16); + assert!(!first + .iter() + .any(|message| matches!(message, ServerMessage::GraphicsFile { .. }))); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + // The remaining large asset cannot fit even alone. It remains absent from + // delivery state, but the identical impossible frame is not rescheduled forever. + server.render_and_stream_with_test_graphics_limit(exact_small_limit); + let second = drain_native_render_messages(&writer); + let second_surface = second + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("metadata-only bounded fallback"); + assert!(second_surface.graphics.assets.is_empty()); + assert!(server.clients[&1].shell_graphics_delivery.has_pending()); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn quiet_native_producer_geometry_retirement_schedules_full_inline_recovery() { + for retained in [false, true] { + let (mut server, _control, _render, pane) = retained_test_server_with_control( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = true; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + server.app.render_dirty.take(); + server.render_and_stream(); + + // The writer has already drained, so no future writer progress can wake + // the server after geometry cancellation. The native ACK is still pending. + let initial = drain_native_render_messages(&writer); + let metadata_index = initial + .iter() + .position(|message| matches!(message, ServerMessage::PaneSurface(_))) + .expect("native metadata scene"); + let file_index = initial + .iter() + .position(|message| matches!(message, ServerMessage::GraphicsFile { .. })) + .expect("native file upload"); + assert!(metadata_index < file_index); + let ServerMessage::PaneSurface(metadata) = &initial[metadata_index] else { + unreachable!() + }; + assert!(metadata.graphics.assets.is_empty()); + assert_eq!(metadata.graphics.placements.len(), 1); + assert_eq!(metadata.graphics.placements[0].cols, 1); + let ServerMessage::GraphicsFile { + transfer_id, + image_id, + path, + .. + } = &initial[file_index] + else { + unreachable!() + }; + let (token, image, native_path) = (*transfer_id, *image_id, PathBuf::from(path)); + assert!(native_path.exists()); + assert!(server.native_graphics.is_pending(1)); + assert!(writer.test_drain().is_empty()); + native_started(&mut server, 1, token, image); + + // A single producer update moves/resizes the placement, then goes quiet. + // Re-upload the same pixels to replace placement p=3 with two columns. + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=2,r=1,q=2;/wAA/w==\x1b\\", + ); + server.app.render_dirty.take(); // model the main loop consuming this PTY wake + assert!(!server.app.render_dirty.is_pending()); + if retained { + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane]))); + } else { + server.render_and_stream(); + } + assert!(!server.native_graphics.is_pending(1), "retained={retained}"); + assert!(!native_path.exists()); + assert!( + server.app.render_dirty.is_pending(), + "geometry retirement must independently wake the quiet server; retained={retained}" + ); + assert!(server.app.render_dirty.has_immediate_work()); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + let retired = drain_native_render_messages(&writer); + assert!(retired.iter().any(|message| matches!(message, + ServerMessage::GraphicsTransmissionRetired { transfer_id, image_id } + if *transfer_id == token && *image_id == image + ))); + assert!(!retired.iter().any(|message| matches!( + message, + ServerMessage::PaneSurface(_) | ServerMessage::GraphicsFile { .. } + ))); + + // A stale success must not restore native residency or consume the full + // recovery wake. No producer bytes or writer events occur after this ACK. + assert!(!native_result(&mut server, 1, token, image, true)); + assert!(!server.native_graphics.is_pending(1)); + let request = server.app.render_dirty.take(); + assert!( + request.generic, + "generic full-render request must survive late ACK; retained={retained}" + ); + server.render_and_stream(); + let recovered = drain_native_render_messages(&writer); + assert!(!recovered + .iter() + .any(|message| matches!(message, ServerMessage::GraphicsFile { .. }))); + let surface = recovered + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect( + "next scheduled full render delivers inline fallback without new producer activity", + ); + assert_eq!(surface.graphics.placements.len(), 1); + assert_eq!(surface.graphics.placements[0].cols, 2); + assert_eq!(surface.graphics.assets.len(), 1); + assert_eq!(surface.graphics.assets[0].data, [255, 0, 0, 255]); + assert!(server.clients[&1].direct_graphics); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + shutdown_test_runtimes(&mut server); + } +} + +#[tokio::test] +#[ignore = "manual native-file retained-render scaling profile; one image, 1/15 populated panes"] +async fn native_file_render_scale_profile() { + use base64::Engine as _; + use ratatui::layout::Direction; + + const WARMUP: usize = 5; + const SAMPLES: usize = 35; + const IMAGE_WIDTH: u32 = 800; + const IMAGE_HEIGHT: u32 = 480; + for count in [1, 15] { + for (native, source_retention) in [(false, false), (true, false), (true, true)] { + let (mut server, _control, _render, root) = + retained_test_server_with_control(b"populated root terminal\r\n"); + if source_retention { + // Test runtimes normally leave file transmission disabled. Enable + // it and the snapshot callback only for this source-producing pane. + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, root) + .unwrap() + .test_enable_kitty_source_forwarding(); + } + let mut panes = vec![root]; + for index in 1..count { + let workspace = &mut server.app.state.workspaces[0]; + workspace.tabs[0].layout.focus_pane(panes[(index - 1) / 2]); + let pane = workspace.test_split(if index % 2 == 0 { + Direction::Vertical + } else { + Direction::Horizontal + }); + workspace.insert_test_runtime( + pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 160, + 48, + b"populated terminal\r\nsecond row\r\nthird row\r\n", + ), + ); + panes.push(pane); + } + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.terminal_size = (160, 48); + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + // Compare actual capability fallback with automatic native delivery. + client.direct_graphics = native; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + server.render_and_stream(); + let baseline = drain_native_render_messages(&writer); + assert!(baseline + .iter() + .any(|message| matches!(message, ServerMessage::PaneSurface(_)))); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + let sources = panes.iter().copied().collect::<HashSet<_>>(); + + // Deterministic noisy RGB, opaque alpha. Only the first pixel changes + // each submission; no timing includes producer/base64 construction. + let mut rgba = vec![0u8; IMAGE_WIDTH as usize * IMAGE_HEIGHT as usize * 4]; + let mut noise = 0x1234_5678u32; + for pixel in rgba.chunks_exact_mut(4) { + noise = noise.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + pixel[..3].copy_from_slice(&noise.to_le_bytes()[..3]); + pixel[3] = 255; + } + // Private /var/tmp native-store generation owns the producer path: + // bounded to one image and removed even if an assertion unwinds. + let producer_store = crate::pane_graphics_files::FileStore::native_sources(); + let producer = source_retention + .then(|| producer_store.export(&rgba).expect("private producer file")); + let mut previous_source_path: Option<PathBuf> = None; + let mut times = Vec::with_capacity(SAMPLES); + for sample in 0..WARMUP + SAMPLES { + assert!(!server.native_graphics.is_pending(1)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + assert!(writer.test_drain().is_empty()); + rgba[..3].copy_from_slice(&(sample as u32 + 1).to_le_bytes()[..3]); + let mut payload = Vec::new(); + payload.extend_from_slice(b"\x1b[H"); + if let Some(producer) = &producer { + std::fs::write(producer.path(), &rgba).unwrap(); + let encoded_path = base64::engine::general_purpose::STANDARD + .encode(producer.path().as_os_str().as_encoded_bytes()); + payload.extend_from_slice(format!( + "\x1b_Ga=T,f=32,t=f,i=7,p=3,s={IMAGE_WIDTH},v={IMAGE_HEIGHT},c=1,r=1,q=2;{encoded_path}\x1b\\" + ).as_bytes()); + } else { + let encoded = base64::engine::general_purpose::STANDARD.encode(&rgba); + let chunks = encoded.as_bytes().chunks(4096); + let chunk_count = chunks.len(); + for (index, chunk) in chunks.enumerate() { + let more = u8::from(index + 1 < chunk_count); + if index == 0 { + payload.extend_from_slice(format!( + "\x1b_Ga=T,f=32,t=d,i=7,p=3,s={IMAGE_WIDTH},v={IMAGE_HEIGHT},c=1,r=1,q=2,m={more};" + ).as_bytes()); + } else { + payload.extend_from_slice(format!("\x1b_Gm={more};").as_bytes()); + } + payload.extend_from_slice(chunk); + payload.extend_from_slice(b"\x1b\\"); + } + } + for pane in &panes { + write_shared_test_pane( + &mut server, + *pane, + format!("\x1b[Hframe {sample:03}").as_bytes(), + ); + } + write_shared_test_pane(&mut server, root, &payload); + let retained_source_path = if source_retention { + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, root) + .unwrap(); + let placements = + runtime.kitty_image_placements_with_data_filter(|descriptor| { + assert!(descriptor.source_file, + "source profile requires working CoW retention, not decoded fallback"); + false + }); + assert_eq!(placements.len(), 1); + assert!(placements[0].data.is_empty()); + let source = placements[0].source_file.as_ref().expect("retained source"); + let path = source.path().to_owned(); + if let Some(previous) = previous_source_path.take() { + assert_ne!(previous, path); + assert!(!previous.exists(), "replacement releases previous source"); + } + Some(path) + } else { + None + }; + server.app.render_dirty.take(); + + let started = Instant::now(); + let retained = server.render_retained_pane_surface_and_stream(&sources); + let elapsed = started.elapsed(); + assert!( + retained, + "profile must not silently fall back to full rendering" + ); + if sample >= WARMUP { + times.push(elapsed); + } + + // Validate outside timing. Decoded exports close on ACK; source + // snapshots remain owned by the image until its replacement. + let messages = drain_native_render_messages(&writer); + let surfaces = messages + .iter() + .filter_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(surfaces.len(), 1); + let graphics = &surfaces[0].graphics; + assert_eq!(graphics.placements.len(), 1); + assert_eq!(graphics.placements[0].asset.image_width, IMAGE_WIDTH); + assert_eq!(graphics.placements[0].asset.image_height, IMAGE_HEIGHT); + let files = messages + .iter() + .filter_map(|message| match message { + ServerMessage::GraphicsFile { + path, + expected_len, + transfer_id, + image_id, + .. + } => Some((path, *expected_len, *transfer_id, *image_id)), + _ => None, + }) + .collect::<Vec<_>>(); + if native { + assert!(graphics.assets.is_empty()); + assert_eq!(files.len(), 1); + let (path, expected_len, token, image) = files[0]; + assert_eq!(expected_len, rgba.len() as u64); + assert_eq!(std::fs::read(path).unwrap(), rgba); + assert_ne!(token & NATIVE_TRANSFER_BIT, 0); + native_started(&mut server, 1, token, image); + native_result(&mut server, 1, token, image, true); + if source_retention { + assert_eq!( + retained_source_path.as_deref(), + Some(std::path::Path::new(path)) + ); + assert!( + std::path::Path::new(path).exists(), + "ACK must preserve image backing" + ); + previous_source_path = Some(PathBuf::from(path)); + } else { + assert!(!std::path::Path::new(path).exists()); + } + } else { + assert!(files.is_empty()); + assert_eq!(graphics.assets.len(), 1); + assert_eq!(graphics.assets[0].data, rgba); + } + assert!(!server.native_graphics.is_pending(1)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + } + times.sort_unstable(); + println!( + "native_file_render_scale retained=true native_files={native} source_retention={source_retention} viewport=160x48 populated_panes={count} changing_images=1 image=800x480 warmup={WARMUP} samples={SAMPLES} median_us={} p95_us={}", + times[SAMPLES / 2].as_micros(), times[SAMPLES * 95 / 100].as_micros(), + ); + shutdown_test_runtimes(&mut server); + drop(server); + if let Some(path) = previous_source_path { + // Aborted compression tasks release their terminal reference on poll. + for _ in 0..100 { + if !path.exists() { + break; + } + tokio::task::yield_now().await; + } + assert!(!path.exists(), "terminal shutdown releases final source"); + } + } + } +} + +fn source_backed_scene( + source: &Arc<crate::pane_graphics_files::OwnedExport>, +) -> ( + SurfaceGraphicsScene, + crate::kitty_graphics::surface::SourceFiles, +) { + let mut desired = scene(); + let key = &mut desired.assets[0].key; + key.image_width = (source.len() / 4) as u32; + key.data_len = source.len() as u64; + key.data_fingerprint = source.fingerprint(); + desired.placements[0].asset = key.clone(); + desired.placements[0].source_width = key.image_width; + let sources = std::collections::HashMap::from([(key.clone(), Arc::clone(source))]); + desired.assets[0].data.clear(); + (desired, sources) +} + +fn assert_source_upload_and_ack( + source: &Arc<crate::pane_graphics_files::OwnedExport>, + pixels: &[u8], +) { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (mut desired, mut sources) = source_backed_scene(source); + let mut delivery = DeliveryCache::default(); + let (pending, message) = server + .prepare_native_scene(7, &mut desired, &mut delivery, &mut sources) + .expect("source-backed native upload"); + assert!(desired.assets.is_empty(), "metadata must not carry pixels"); + assert_eq!(desired.placements.len(), 1); + assert!(sources.is_empty()); + let ServerMessage::GraphicsFile { + path, + transfer_id, + image_id, + expected_len, + surface_asset, + leading, + control, + } = message + else { + panic!("expected path-only upload"); + }; + assert_eq!( + PathBuf::from(&path), + source.path(), + "reuse source, not decoded export" + ); + assert_eq!(expected_len, pixels.len() as u64); + assert_eq!(surface_asset.as_ref(), Some(&desired.placements[0].asset)); + assert!(leading.is_empty()); + assert_eq!( + control, + format!("a=t,f=32,s={},v=1,i={image_id},q=0", pixels.len() / 4) + ); + assert_ne!(transfer_id & NATIVE_TRANSFER_BIT, 0); + assert_eq!(std::fs::read(&path).unwrap(), pixels); + commit_scene(&mut server, 7, &desired); + server.native_graphics.commit(7, pending); + native_started(&mut server, 7, transfer_id, image_id); + native_result(&mut server, 7, transfer_id, image_id, true); + assert!(!server.native_graphics.is_pending(7)); + // The producer image's Arc still owns the backing after the transfer ACK. + assert_eq!(std::fs::read(&path).unwrap(), pixels); + assert_eq!(source.copy_rgba().unwrap(), pixels); +} + +#[test] +fn source_backed_native_upload_reuses_path_and_ack_preserves_image_backing() { + let store = crate::pane_graphics_files::FileStore::native_sources(); + let pixels = [1, 2, 3, 255]; + let source = Arc::new(store.export(&pixels).unwrap()); + let path = source.path().to_owned(); + assert_source_upload_and_ack(&source, &pixels); + drop(source); + assert!(!path.exists(), "final image owner releases the backing"); +} + +#[test] +fn source_backed_client_without_direct_support_materializes_exact_pixels() { + let store = crate::pane_graphics_files::FileStore::native_sources(); + let pixels = [1, 2, 3, 255, 200, 100, 50, 0]; + let source = Arc::new(store.export(&pixels).unwrap()); + let (mut desired, mut sources) = source_backed_scene(&source); + let expected_placements = desired.placements.clone(); + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + server.clients.get_mut(&7).unwrap().direct_graphics = false; + assert!(server + .prepare_native_scene(7, &mut desired, &mut DeliveryCache::default(), &mut sources) + .is_none()); + assert!(sources.is_empty()); + assert_eq!(desired.assets.len(), 1); + assert_eq!(desired.assets[0].data, pixels); + assert_eq!(desired.placements, expected_placements); + assert!(!server.native_graphics.is_pending(7)); +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshotted_source_survives_producer_unlink_and_native_ack() { + use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt as _; + + let store = crate::pane_graphics_files::FileStore::native_sources(); + let producer_path = store.source_directory().unwrap().join("producer.rgba"); + let pixels = vec![73; 4096]; + let mut producer = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&producer_path) + .unwrap(); + std::io::Write::write_all(&mut producer, &pixels).unwrap(); + let snapshot = store.snapshot(i64::from(producer.as_raw_fd()), pixels.len()); + std::fs::remove_file(&producer_path).unwrap(); + drop(producer); + let source = match snapshot { + Ok(source) => Arc::new(source), + Err(error) => { + assert!( + error.kind() == std::io::ErrorKind::Unsupported + || matches!( + error.raw_os_error(), + Some( + libc::EOPNOTSUPP + | libc::ENOTTY + | libc::EXDEV + | libc::EINVAL + | libc::ENOSYS + ) + ), + "unexpected snapshot failure: {error}" + ); + eprintln!("native snapshot unsupported on test filesystem: {error}"); + return; + } + }; + assert!(!producer_path.exists()); + let path = source.path().to_owned(); + assert_source_upload_and_ack(&source, &pixels); + drop(source); + assert!(!path.exists()); +} + +#[tokio::test] +async fn failed_source_read_retries_after_identical_suppression_without_producer_activity() { + let (mut server, _control, _render, _pane) = + retained_test_server_with_control(b"quiet source producer"); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + server.clients.get_mut(&1).unwrap().direct_graphics = false; + server.render_and_stream(); + let mut surface = drain_native_render_messages(&writer) + .into_iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("initial quiet surface"); + + // Inject the collected sidecar at the scheduler boundary, avoiding global + // environment changes and a filesystem-dependent CoW requirement. + let store = crate::pane_graphics_files::FileStore::native_sources(); + let pixels = [1, 2, 3, 255]; + let source = Arc::new(store.export(&pixels).unwrap()); + std::fs::write(source.path(), []).unwrap(); + let mut delivery = DeliveryCache::default(); + for attempt in 0..2 { + let (mut graphics, mut sources) = source_backed_scene(&source); + assert!(server + .prepare_native_scene(1, &mut graphics, &mut delivery, &mut sources) + .is_none()); + assert!(graphics.assets.is_empty()); + surface.graphics = graphics; + let client = server.clients.get_mut(&1).unwrap(); + let prepared = client.render_state.prepare_pane_surface(surface.clone()); + if attempt == 0 { + client + .render_state + .commit_sent_frame(prepared.expect("first failed scene")); + } else { + assert!( + prepared.is_none(), + "repeated read failure produces identical metadata" + ); + // Exactly the full-render identical-frame suppression branch. + client.clear_deferred_render(); + } + } + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + server.app.render_dirty.take(); + + // The timer is one-shot: an inactive client does not receive repeated wakes + // unless a real materialization attempt fails and explicitly re-arms it. + let tick = Instant::now() + Duration::from_secs(2); + assert!(server.expire_native_graphics(tick)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + server.clients.get_mut(&1).unwrap().clear_deferred_render(); + assert!(!server.expire_native_graphics(tick + Duration::from_secs(3600))); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + + let (mut failed_graphics, mut failed_sources) = source_backed_scene(&source); + assert!(server + .prepare_native_scene(1, &mut failed_graphics, &mut delivery, &mut failed_sources) + .is_none()); + assert!(failed_graphics.assets.is_empty()); + let rearmed_tick = tick + Duration::from_secs(7200); + assert!(server.expire_native_graphics(rearmed_tick)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + // No PTY bytes, writer progress, or native ACK occurs after recovery. + std::fs::write(source.path(), pixels).unwrap(); + let (mut graphics, mut sources) = source_backed_scene(&source); + assert!(server + .prepare_native_scene(1, &mut graphics, &mut delivery, &mut sources) + .is_none()); + assert_eq!(graphics.assets[0].data, pixels); + surface.graphics = graphics; + let client = server.clients.get_mut(&1).unwrap(); + let prepared = client + .render_state + .prepare_pane_surface(surface) + .expect("recovered pixels must not be suppressed as identical"); + let ServerMessage::PaneSurface(recovered) = prepared.message() else { + panic!("expected recovered surface"); + }; + assert_eq!(recovered.graphics.assets[0].data, pixels); + client.render_state.commit_sent_frame(prepared); + client.clear_deferred_render(); + assert!( + !server.expire_native_graphics(rearmed_tick + Duration::from_secs(2)), + "successful materialization leaves the one-shot retry timer disarmed" + ); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + shutdown_test_runtimes(&mut server); +} diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 3e36a8fc..190eaa80 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -4,93 +4,6 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati receiver.recv_timeout(timeout).unwrap() } -#[tokio::test] -async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell graphics"); - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); - - server.render_and_stream(); - let first = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(first) = first else { - panic!("expected client shell pane surface"); - }; - assert_eq!(first.graphics.placements.len(), 1); - assert_eq!(first.graphics.assets.len(), 1); - assert_eq!(first.graphics.assets[0].data, vec![1, 2, 3, 4]); - assert!(matches!( - first.graphics.placements[0].asset.source, - crate::protocol::SurfaceGraphicsSource::PaneLayer { .. } - )); - - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let second = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(second) = second else { - panic!("expected replacement client shell pane surface"); - }; - assert_eq!(second.graphics.placements, first.graphics.placements); - assert!(second.graphics.assets.is_empty()); - - // A post-commit typed surface.set(true) resets only this viewer's delivery cache, so the - // already selected target receives the asset again without relying on graphics that may have - // arrived during the frozen source frame. - assert!(server.set_client_shell_surface_active(1, true).is_some()); - server.render_and_stream(); - let replay = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(replay) = replay else { - panic!("expected post-commit graphics replay"); - }; - assert_eq!(replay.graphics.placements, first.graphics.placements); - assert_eq!(replay.graphics.assets.len(), 1); - assert_eq!(replay.graphics.assets[0].data, vec![1, 2, 3, 4]); -} - -#[tokio::test] -async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell graphics"); - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); - - server.render_and_stream(); - let _first = receive_render(&client_rx, Duration::from_millis(100)); - server.app.pane_graphics.slots.clear(); - server.app.pane_graphics.mark_changed(); - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let removed = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(removed) = removed else { - panic!("expected removed client shell scene"); - }; - assert!(removed.graphics.placements.is_empty()); - - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let restored = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(restored) = restored else { - panic!("expected restored client shell scene"); - }; - assert_eq!(restored.graphics.assets.len(), 1); - assert_eq!(restored.graphics.assets[0].data, vec![1, 2, 3, 4]); -} - #[tokio::test] async fn first_kitty_image_updates_retained_surface_without_full_redraw() { let (mut server, _control_rx, client_rx, pane_id) = @@ -409,87 +322,6 @@ async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { assert!(surface.graphics.assets.is_empty()); } -#[tokio::test] -async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell graphics"); - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - set_graphics_layer(&mut server, pane_id, vec![5, 6, 7, 8]); - - fill_render_lane(&server); - server.render_and_stream(); - let _older = receive_render(&client_rx, Duration::from_millis(100)); - server.render_and_stream(); - let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(surface) = message else { - panic!("expected client shell pane surface"); - }; - assert_eq!(surface.graphics.assets.len(), 1); - assert_eq!(surface.graphics.assets[0].data, vec![5, 6, 7, 8]); -} - -fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key { - (pane_id, api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()) -} - -fn active_gate() -> std::sync::Arc<std::sync::atomic::AtomicBool> { - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) -} - -fn set_graphics_layer(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, data: Vec<u8>) { - set_named_graphics_layer( - server, - pane_id, - api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID, - data, - 0, - ); -} - -fn set_named_graphics_layer( - server: &mut HeadlessServer, - pane_id: crate::layout::PaneId, - layer_id: &str, - data: Vec<u8>, - z_index: i32, -) { - let key = (pane_id, layer_id.into()); - let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap(); - let layer = crate::app::pane_graphics::Layer::inline( - api::schema::PaneGraphicsFormat::Png, - 1, - 1, - data, - Default::default(), - z_index, - ); - server.app.pane_graphics.slots.insert( - key, - crate::app::pane_graphics::Slot::test(host_image_id, Some(layer)), - ); -} - -fn set_stream_owner(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, owner: &str) { - let key = graphics_key(pane_id); - if let Some(slot) = server.app.pane_graphics.slots.get_mut(&key) { - slot.stream_owner = Some(owner.into()); - slot.stream_active = Some(active_gate()); - } else { - let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap(); - let mut slot = crate::app::pane_graphics::Slot::test(host_image_id, None); - slot.stream_owner = Some(owner.into()); - slot.stream_active = Some(active_gate()); - server.app.pane_graphics.slots.insert(key, slot); - } -} - fn fill_render_lane(server: &HeadlessServer) { let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("dummy frame"); @@ -500,105 +332,6 @@ fn fill_render_lane(server: &HeadlessServer) { .test_fill_render(queued); } -fn stream_set_message( - id: &str, - pane_id: &str, - owner: &str, - data: Vec<u8>, -) -> (api::ApiRequestMessage, std::sync::mpsc::Receiver<String>) { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - ( - api::ApiRequestMessage { - request: api::schema::Request { - id: id.into(), - method: api::schema::Method::PaneGraphicsStreamSet( - api::schema::PaneGraphicsSetParams { - pane_id: pane_id.into(), - layer_id: None, - z_index: 0, - owner: owner.into(), - format: api::schema::PaneGraphicsFormat::Png, - image_width: 1, - image_height: 1, - data: Some(data), - data_base64: String::new(), - placement: api::schema::PaneGraphicsPlacementParams::default(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }, - response_rx, - ) -} - -#[cfg(unix)] -fn sparse_direct_frame( - server: &HeadlessServer, - name: &str, - image_width: u32, - image_height: u32, -) -> String { - use std::os::unix::fs::OpenOptionsExt as _; - - let path = server - .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(u64::from(image_width) * u64::from(image_height) * 4) - .unwrap(); - path.to_string_lossy().into_owned() -} - -#[cfg(unix)] -fn direct_stream_message( - id: &str, - pane_id: &str, - owner: &str, - path: String, - image_width: u32, - image_height: u32, - sequence: u64, -) -> (api::ApiRequestMessage, std::sync::mpsc::Receiver<String>) { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - ( - api::ApiRequestMessage { - request: api::schema::Request { - id: id.into(), - method: api::schema::Method::PaneGraphicsStreamDirect( - api::schema::PaneGraphicsDirectParams { - pane_id: pane_id.into(), - layer_id: None, - z_index: 0, - owner: owner.into(), - image_width, - image_height, - format: api::schema::PaneGraphicsFormat::Rgba, - path, - sequence, - revision: 1, - placement: Default::default(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }, - response_rx, - ) -} - #[tokio::test] async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { let (mut server, _client_rx, _pane_id) = @@ -614,7 +347,6 @@ async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { }; client.host_mouse_capture_active = None; client.host_sgr_pixels_active = None; - server.app.direct_graphics_available = false; server.stream_host_mouse_capture_mode(); assert!(matches!( @@ -625,868 +357,3 @@ async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { } )); } - -#[tokio::test] -async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { - let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa"); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - - assert!(!server - .app - .pane_graphics - .retain_live_panes(&server.app.state)); - assert!(server - .app - .pane_graphics - .slots - .contains_key(&graphics_key(pane_id))); - - server.app.state.workspaces.clear(); - assert!(server - .app - .pane_graphics - .retain_live_panes(&server.app.state)); - assert!(server.app.pane_graphics.slots.is_empty()); -} - -#[test] -fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() { - let mut server = test_headless_server(); - server.app.state.kitty_graphics_enabled = true; - let workspace = crate::workspace::Workspace::test_new("gated"); - let pane_id = workspace.tabs[0].root_pane; - let public = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - let active = active_gate(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "open-gated".into(), - method: api::schema::Method::PaneGraphicsStreamOpen( - api::schema::PaneGraphicsStreamParams { - pane_id: public.clone(), - layer_id: None, - z_index: 0, - owner: "worker-1".into(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: Some(active.clone()), - }); - assert!( - serde_json::from_str::<api::schema::SuccessResponse>(&response_rx.recv().unwrap()).is_ok() - ); - let (frame, frame_response) = - stream_set_message("gated-frame", &public, "worker-1", vec![1, 2, 3]); - assert_eq!( - server.handle_api_request_with_render_impact(frame), - RenderImpact::Graphics - ); - assert!(frame_response.recv().is_ok()); - assert!(active.load(std::sync::atomic::Ordering::Acquire)); - active.store(false, std::sync::atomic::Ordering::Release); - let (delayed, delayed_response) = - stream_set_message("delayed-frame", &public, "worker-1", vec![4, 5, 6]); - assert_eq!( - server.handle_api_request_with_render_impact(delayed), - RenderImpact::None - ); - let error: api::schema::ErrorResponse = - serde_json::from_str(&delayed_response.recv().unwrap()).unwrap(); - assert_eq!(error.error.code, "stream_closed"); - assert!(server - .app - .pane_graphics - .slots - .remove(&graphics_key(pane_id)) - .is_some()); - assert!(!active.load(std::sync::atomic::Ordering::Acquire)); -} - -#[test] -fn stream_set_has_graphics_only_render_impact() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("graphics"); - let pane_id = workspace.tabs[0].root_pane; - let public_pane_id = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.kitty_graphics_enabled = true; - set_stream_owner(&mut server, pane_id, "owner-a"); - - let (request, response_rx) = - stream_set_message("wrong-owner", &public_pane_id, "owner-b", vec![1, 2, 3]); - assert_eq!( - server.handle_api_request_with_render_impact(request), - RenderImpact::None - ); - assert!(serde_json::from_str::<api::schema::ErrorResponse>( - &response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap() - ) - .is_ok()); - - let (request, response_rx) = - stream_set_message("stream-frame", &public_pane_id, "owner-a", vec![1, 2, 3]); - assert_eq!( - server.handle_api_request_with_render_impact(request), - RenderImpact::Graphics - ); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap() - ) - .is_ok()); - - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: "9.9.9".into(), - install_command: "herdr update".into(), - }) - .unwrap(); - let (request, _response_rx) = stream_set_message( - "stream-frame-with-internal-event", - &public_pane_id, - "owner-a", - vec![4, 5, 6], - ); - assert_eq!( - server.handle_api_request_with_render_impact(request), - RenderImpact::Full - ); - - server.app.pane_graphics.clear(); - let (respond_to, _response_rx) = std::sync::mpsc::channel(); - let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage { - request: api::schema::Request { - id: "direct-frame".into(), - method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams { - pane_id: public_pane_id, - layer_id: None, - z_index: 0, - owner: String::new(), - format: api::schema::PaneGraphicsFormat::Png, - image_width: 1, - image_height: 1, - data: Some(vec![1, 2, 3]), - data_base64: String::new(), - placement: api::schema::PaneGraphicsPlacementParams::default(), - }), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - assert_eq!(impact, RenderImpact::Full); -} - -#[test] -fn rejected_or_stale_requests_do_not_schedule_rendering() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("graphics"); - let pane_id = workspace.tabs[0].root_pane; - let public_pane_id = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.kitty_graphics_enabled = false; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "disabled-set".into(), - method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams { - pane_id: public_pane_id.clone(), - layer_id: None, - z_index: 0, - owner: String::new(), - format: api::schema::PaneGraphicsFormat::Png, - image_width: 1, - image_height: 1, - data: Some(vec![1, 2, 3]), - data_base64: String::new(), - placement: api::schema::PaneGraphicsPlacementParams::default(), - }), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - assert!(!changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - assert_eq!( - serde_json::from_str::<api::schema::ErrorResponse>(&response) - .unwrap() - .error - .code, - "feature_disabled" - ); - - server.app.state.kitty_graphics_enabled = true; - set_stream_owner(&mut server, pane_id, "current-owner"); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage { - request: api::schema::Request { - id: "stale-close".into(), - method: api::schema::Method::PaneGraphicsStreamClose( - api::schema::PaneGraphicsStreamParams { - pane_id: public_pane_id, - layer_id: None, - z_index: 0, - owner: "stale-owner".into(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - assert_eq!(impact, RenderImpact::None); - assert_eq!( - server - .app - .pane_graphics - .slots - .get(&graphics_key(pane_id)) - .and_then(|slot| slot.stream_owner.as_deref()), - Some("current-owner") - ); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap() - ) - .is_ok()); -} - -#[cfg(unix)] -#[tokio::test] -async fn direct_graphics_availability_follows_foreground_client_with_background_clients() { - let (mut server, _client_rx, _pane_id) = retained_test_server(b"direct eligibility"); - let foreground = server.clients.get_mut(&1).unwrap(); - foreground.direct_graphics = true; - foreground.pixel_mouse = true; - - let (background_writer, _background_control_rx, _background_render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - 2, - RenderEncoding::SemanticFrame, - Some(background_writer), - ), - ); - - server.sync_foreground_client_state(); - assert!(server.direct_graphics_available()); - assert!(server.app.direct_graphics_available); - - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - assert!(!server.direct_graphics_available()); - assert!(!server.app.direct_graphics_available); -} - -#[cfg(unix)] -#[test] -fn direct_graphics_routing_prefers_the_target_stream_owner() { - let (mut server, first_key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - add_direct_client(&mut server, 8); - server.clients.get_mut(&8).unwrap().last_activity = 2; - let (transfer_id, image_id) = direct_ids(&server, &first_key); - assert!(server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert_eq!(response_rx.recv().unwrap(), "ack"); - - let second_key = (first_key.0, "second".into()); - let second_layer = crate::app::pane_graphics::Layer { - format: crate::api::schema::PaneGraphicsFormat::Rgba, - image_width: 1, - image_height: 1, - backing: crate::app::pane_graphics::Backing::Resident { - len: 4, - client_id: 8, - }, - data_fingerprint: 2, - render: Default::default(), - z_index: 0, - }; - server.app.pane_graphics.slots.insert( - second_key.clone(), - crate::app::pane_graphics::Slot::test((1 << 31) | 901, Some(second_layer)), - ); - - assert_eq!(server.direct_graphics_client_for_key(&first_key), Some(7)); - assert_eq!(server.direct_graphics_client_for_key(&second_key), Some(8)); - assert_eq!( - server.direct_graphics_client_for_key(&(first_key.0, "new".into())), - Some(8) - ); -} - -#[cfg(unix)] -#[test] -fn resident_direct_stream_survives_non_direct_client_becoming_foreground() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - server.foreground_client_id = Some(7); - server.sync_foreground_client_state(); - let (transfer_id, image_id) = direct_ids(&server, &key); - assert!(server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert_eq!(response_rx.recv().unwrap(), "ack"); - - let (writer, _control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 8, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - 2, - RenderEncoding::SemanticFrame, - Some(writer), - ), - ); - server.foreground_client_id = Some(8); - server.sync_foreground_client_state(); - - assert!(server.direct_graphics_available()); - assert!(server.app.direct_graphics_available); - assert_eq!( - server.app.pane_graphics.slots[&key] - .layer - .as_ref() - .and_then(crate::app::pane_graphics::Layer::resident_client), - Some(7) - ); - - server.remove_client_and_resize_if_needed(7); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(!server.direct_graphics_available()); - assert!(!server.app.direct_graphics_available); -} - -#[cfg(unix)] -#[tokio::test] -async fn client_shell_direct_graphics_uploads_without_server_authored_coordinates() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell direct"); - server.app.state.kitty_graphics_enabled = true; - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - client.direct_graphics = true; - client.pixel_mouse = true; - server.app.direct_graphics_available = true; - let (background_writer, _background_control_rx, background_render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 2, - RenderEncoding::SemanticFrame, - Some(background_writer), - ), - ); - set_stream_owner(&mut server, pane_id, "browser"); - let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); - let path = sparse_direct_frame(&server, "client-shell-direct.rgba", 1, 1); - let (message, response_rx) = - direct_stream_message("shell-direct", &public_pane_id, "browser", path, 1, 1, 1); - - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::None - ); - let (transfer_id, image_id, asset) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("client shell direct upload"), - ) { - ServerMessage::GraphicsFile { - transfer_id, - image_id, - leading, - control, - surface_asset: Some(asset), - .. - } => { - assert!(leading.is_empty()); - assert!(control.starts_with("a=t,"), "{control}"); - assert!(!control.contains("\u{1b}["), "{control}"); - (transfer_id, image_id, asset) - } - other => panic!("expected client shell graphics file, got {other:?}"), - }; - assert!(background_render_rx.try_recv().is_err()); - assert_eq!( - image_id, - crate::kitty_graphics::surface::host_image_id(&server.client_shell_boot_id, &asset) - ); - let (pending, _) = crate::server::client_shell_graphics::collect( - &server.app, - &[], - &[], - None, - Some(crate::ui::TabSurfaceTarget { - workspace_index: 0, - tab_index: 0, - }), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - &crate::kitty_graphics::surface::DeliveryCache::default(), - 1, - ); - assert_eq!(pending.retained_assets, vec![asset.clone()]); - - server.start_direct_graphics_response(1, transfer_id, image_id); - assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() - ) - .is_ok()); - - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - let next_path = sparse_direct_frame(&server, "client-shell-direct-next.rgba", 1, 1); - let (next_message, next_response_rx) = direct_stream_message( - "shell-direct-next", - &public_pane_id, - "browser", - next_path, - 1, - 1, - 2, - ); - assert_eq!( - server.handle_pane_graphics_stream_frame(next_message), - RenderImpact::None - ); - let (next_transfer_id, next_image_id, next_asset) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("sticky owner direct upload"), - ) { - ServerMessage::GraphicsFile { - transfer_id, - image_id, - surface_asset: Some(asset), - .. - } => (transfer_id, image_id, asset), - other => panic!("expected sticky owner graphics file, got {other:?}"), - }; - assert!(background_render_rx.try_recv().is_err()); - server.start_direct_graphics_response(1, next_transfer_id, next_image_id); - assert!(server.complete_direct_graphics(1, next_transfer_id, next_image_id, true)); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &next_response_rx - .recv_timeout(Duration::from_secs(1)) - .unwrap() - ) - .is_ok()); - assert!(server.app.direct_graphics_available); - - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let surface = read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("resident client shell scene"), - ); - let ServerMessage::PaneSurface(surface) = surface else { - panic!("expected client shell pane surface"); - }; - assert_eq!(surface.graphics.placements.len(), 1); - assert!(surface.graphics.assets.is_empty()); - assert_eq!(surface.graphics.placements[0].asset, next_asset); - assert_eq!(surface.graphics.retained_assets, vec![next_asset.clone()]); - - let background_surface = read_server_message( - background_render_rx - .recv_timeout(Duration::from_secs(1)) - .expect("non-owning client shell scene"), - ); - let ServerMessage::PaneSurface(background_surface) = background_surface else { - panic!("expected non-owning client shell pane surface"); - }; - assert!(background_surface.graphics.assets.is_empty()); - assert!(background_surface.graphics.placements.is_empty()); - assert!(background_surface.graphics.retained_assets.is_empty()); - - let (hidden, _) = crate::server::client_shell_graphics::collect( - &server.app, - &[], - &[], - None, - Some(crate::ui::TabSurfaceTarget { - workspace_index: 0, - tab_index: 0, - }), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - &crate::kitty_graphics::surface::DeliveryCache::default(), - 1, - ); - assert!(hidden.placements.is_empty()); - assert_eq!(hidden.retained_assets, vec![next_asset]); -} - -#[cfg(unix)] -fn direct_gate_server( - data: &[u8], -) -> ( - HeadlessServer, - crate::app::pane_graphics::Key, - std::sync::mpsc::Receiver<String>, -) { - direct_gate_server_with_file(data.len(), Some(data)) -} - -#[cfg(unix)] -fn direct_gate_server_with_file( - len: usize, - data: Option<&[u8]>, -) -> ( - HeadlessServer, - crate::app::pane_graphics::Key, - std::sync::mpsc::Receiver<String>, -) { - use std::io::Write as _; - use std::os::unix::fs::OpenOptionsExt as _; - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("direct-gate"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - let key = graphics_key(pane_id); - let path = server - .app - .pane_graphics_files - .source_directory() - .unwrap() - .join("gate-frame"); - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - .unwrap(); - if let Some(data) = data { - file.write_all(data).unwrap(); - } else { - file.set_len(len as u64).unwrap(); - } - drop(file); - let lease = server.app.pane_graphics_files.lease(&path, len).unwrap(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let layer = - crate::app::pane_graphics::Layer::direct(1, 1, lease.clone(), Default::default(), 0); - let mut slot = crate::app::pane_graphics::Slot::test((1 << 31) | 900, Some(layer)); - slot.stream_owner = Some("owner".into()); - slot.stream_active = Some(active_gate()); - slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { - transfer_id: lease.fingerprint(), - image_id: (1 << 31) | 900, - client_id: 7, - deadline: std::time::Instant::now() + Duration::from_secs(1), - written: true, - success_response: "ack".into(), - respond_to, - }); - server.app.pane_graphics.slots.insert(key.clone(), slot); - (server, key, response_rx) -} - -#[cfg(unix)] -fn direct_ids(server: &HeadlessServer, key: &crate::app::pane_graphics::Key) -> (u64, u32) { - let slot = &server.app.pane_graphics.slots[key]; - let gate = slot.direct_gate.as_ref().unwrap(); - (gate.transfer_id, gate.image_id) -} - -#[cfg(unix)] -fn add_direct_client(server: &mut HeadlessServer, client_id: u64) { - let (writer, control_rx, render_rx) = test_client_writer(); - std::mem::forget((control_rx, render_rx)); - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 1, - RenderEncoding::SemanticFrame, - Some(writer), - ); - client.direct_graphics = true; - client.pixel_mouse = true; - server.clients.insert(client_id, client); -} - -#[cfg(unix)] -#[test] -fn terminal_response_deadline_starts_only_after_client_flush() { - let (mut server, key, _response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap(); - let gate = slot.direct_gate.as_mut().unwrap(); - gate.written = false; - let (transfer_id, image_id) = (gate.transfer_id, slot.host_image_id); - assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert!(!server.start_direct_graphics_response(7, transfer_id, image_id)); - let gate = server.app.pane_graphics.slots[&key] - .direct_gate - .as_ref() - .unwrap(); - assert!(gate.written && gate.deadline > std::time::Instant::now()); -} - -#[cfg(unix)] -#[test] -fn outer_timeout_covers_both_direct_phases_and_cancellation_blocks_late_results() { - assert!( - crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT - > crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT - + crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT - ); - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap(); - slot.stream_active - .as_ref() - .unwrap() - .store(false, std::sync::atomic::Ordering::Release); - let (transfer_id, image_id) = ( - slot.direct_gate.as_ref().unwrap().transfer_id, - slot.host_image_id, - ); - - assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert!(response_rx.try_recv().is_err()); - assert!(server.app.pane_graphics.slots[&key] - .layer - .as_ref() - .unwrap() - .direct_lease() - .is_some()); -} - -#[cfg(unix)] -#[test] -fn matching_terminal_ok_releases_producer_and_acknowledges() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (transfer_id, image_id) = direct_ids(&server, &key); - - assert!(server.complete_direct_graphics(7, transfer_id, image_id, true)); - - assert_eq!(response_rx.recv().unwrap(), "ack"); - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - assert!(layer.terminal_only()); - assert!(layer.direct_lease().is_none()); -} - -#[cfg(unix)] -#[test] -fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() { - for error in [ - std::sync::mpsc::TrySendError::Full(Vec::new()), - std::sync::mpsc::TrySendError::Disconnected(Vec::new()), - ] { - let should_ack = matches!(error, std::sync::mpsc::TrySendError::Full(_)); - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - let gate = server - .app - .pane_graphics - .slots - .get_mut(&key) - .and_then(|slot| slot.direct_gate.take()) - .unwrap(); - let result = server.handle_unwritten_direct_failure( - &key, - gate.success_response, - gate.respond_to, - error, - ); - let inline = server - .app - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.layer.as_ref()?.inline_data()) - .is_some(); - assert_eq!( - ( - result, - response_rx.try_recv().ok().as_deref() == Some("ack"), - inline, - server.clients[&7].direct_graphics, - ), - (should_ack, should_ack, should_ack, true) - ); - } -} - -#[cfg(unix)] -#[test] -fn eligibility_loss_cancels_the_queued_direct_upload() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (writer, control_rx, _render_rx) = test_client_writer(); - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 1, - RenderEncoding::SemanticFrame, - Some(writer), - ); - client.direct_graphics = true; - client.pixel_mouse = true; - server.clients.insert(7, client); - let (transfer_id, image_id) = direct_ids(&server, &key); - - server.retire_all_direct_graphics(); - - assert!(matches!( - read_server_message( - control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("direct retirement") - ), - ServerMessage::GraphicsTransmissionRetired { - transfer_id: retired_transfer, - image_id: retired_image, - } if retired_transfer == transfer_id && retired_image == image_id - )); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(response_rx.recv().is_err()); -} - -#[cfg(unix)] -#[test] -fn client_loss_retires_only_its_direct_stream() { - let (mut pending, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - pending.retire_direct_graphics_for_client(8); - assert!(pending.app.pane_graphics.slots.contains_key(&key)); - pending.retire_direct_graphics_for_client(7); - assert!(!pending.app.pane_graphics.slots.contains_key(&key)); - assert!(response_rx.recv().is_err()); - - let (mut resident, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let slot = resident.app.pane_graphics.slots.get(&key).unwrap(); - assert!(resident.complete_direct_graphics( - 7, - slot.direct_gate.as_ref().unwrap().transfer_id, - slot.host_image_id, - true, - )); - assert_eq!(response_rx.recv().unwrap(), "ack"); - resident.retire_direct_graphics_for_client(8); - assert!(resident.app.pane_graphics.slots.contains_key(&key)); - resident.retire_direct_graphics_for_client(7); - assert!(!resident.app.pane_graphics.slots.contains_key(&key)); -} - -#[cfg(unix)] -#[test] -fn pane_removal_cancels_the_pending_client_upload() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (writer, control_rx, _render_rx) = test_client_writer(); - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 1, - RenderEncoding::SemanticFrame, - Some(writer), - ); - client.direct_graphics = true; - client.pixel_mouse = true; - server.clients.insert(7, client); - let (transfer_id, image_id) = direct_ids(&server, &key); - server.app.state.workspaces.clear(); - - assert!(server.retain_live_pane_graphics()); - - assert!(matches!( - read_server_message( - control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("pane removal retirement") - ), - ServerMessage::GraphicsTransmissionRetired { - transfer_id: retired_transfer, - image_id: retired_image, - } if retired_transfer == transfer_id && retired_image == image_id - )); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(response_rx.recv().is_err()); -} - -#[cfg(unix)] -#[test] -fn pane_removal_and_shutdown_drop_direct_without_ack() { - let setups: [fn(&mut HeadlessServer); 2] = [ - |server| server.app.state.workspaces.clear(), - |server| server.shutting_down = true, - ]; - for setup in setups { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (transfer_id, image_id) = direct_ids(&server, &key); - setup(&mut server); - assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert!(response_rx.recv().is_err()); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - } -} - -#[cfg(unix)] -#[test] -fn timeout_retires_stream_without_producer_ack() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - server - .app - .pane_graphics - .slots - .get_mut(&key) - .unwrap() - .direct_gate - .as_mut() - .unwrap() - .deadline = std::time::Instant::now() - Duration::from_millis(1); - - assert!(server.expire_direct_graphics(std::time::Instant::now())); - - assert!(response_rx.recv().is_err()); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(!server.clients[&7].direct_graphics); - assert!(server.clients[&7].pixel_mouse); -} diff --git a/src/server/headless/tests/pane_move.rs b/src/server/headless/tests/pane_move.rs index 789b144f..99b39c8b 100644 --- a/src/server/headless/tests/pane_move.rs +++ b/src/server/headless/tests/pane_move.rs @@ -29,7 +29,6 @@ fn public_move( }, respond_to, response_write_complete: None, - stream_active: None, }); let response = response_rx.recv().expect("pane move response"); match serde_json::from_str::<SuccessResponse>(&response) { diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index db613e96..ee874e52 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -7,7 +7,7 @@ use crate::app::state::AppState; use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; use crate::protocol::{ CursorState, FrameData, PaneSurfaceFrame, PaneSurfacePatch, RenderEncoding, ServerMessage, - TerminalFrame, + SurfaceGraphicsAssetKey, SurfaceGraphicsScene, TerminalFrame, }; use crate::terminal::TerminalRuntimeRegistry; @@ -152,9 +152,18 @@ impl ClientRenderState { } } + #[cfg(test)] pub(crate) fn prepare_pane_surface( + &mut self, + surface: PaneSurfaceFrame, + ) -> Option<PreparedRender> { + self.prepare_pane_surface_with_file(surface, false) + } + + pub(crate) fn prepare_pane_surface_with_file( &mut self, mut surface: PaneSurfaceFrame, + has_file_upload: bool, ) -> Option<PreparedRender> { let Self::Semantic { last_surface, @@ -166,7 +175,8 @@ impl ClientRenderState { else { return None; }; - if !*recompute_pending + if !has_file_upload + && !*recompute_pending && surface.graphics.assets.is_empty() && last_surface.as_deref().is_some_and(|last| { last.projection_revision == surface.projection_revision @@ -182,6 +192,7 @@ impl ClientRenderState { } surface.surface_revision = surface_revision.saturating_add(1); let assets = std::mem::take(&mut surface.graphics.assets); + let queued_graphics_assets = assets.iter().map(|asset| asset.key.clone()).collect(); let committed_surface = surface.clone(); surface.graphics.assets = assets; let mut message = ServerMessage::PaneSurface(surface); @@ -217,6 +228,7 @@ impl ClientRenderState { Some(PreparedRender::Semantic { message: delta.or(reused).unwrap_or(message), committed_surface: Box::new(committed_surface), + queued_graphics_assets, }) } @@ -342,6 +354,7 @@ pub(crate) enum PreparedRender { Semantic { message: ServerMessage, committed_surface: Box<PaneSurfaceFrame>, + queued_graphics_assets: Vec<SurfaceGraphicsAssetKey>, }, SemanticPatch { message: ServerMessage, @@ -362,19 +375,56 @@ impl PreparedRender { } } - pub(crate) fn strip_pane_surface_assets(&mut self) -> bool { + /// Graphics metadata represented by this semantic update plus only the + /// asset keys whose pixel payloads were queued. This is independent of the + /// selected wire codec and avoids cloning asset byte vectors. + pub(crate) fn queued_surface_graphics( + &self, + ) -> Option<(&SurfaceGraphicsScene, &[SurfaceGraphicsAssetKey])> { + match self { + Self::Semantic { + committed_surface, + queued_graphics_assets, + .. + } => Some((&committed_surface.graphics, queued_graphics_assets)), + Self::SemanticPatch { .. } | Self::TerminalAnsi { .. } => None, + } + } + + pub(crate) fn has_queued_surface_assets(&self) -> bool { + matches!(self, Self::Semantic { queued_graphics_assets, .. } if !queued_graphics_assets.is_empty()) + } + + /// Removes the largest inline payload from a full semantic surface while + /// preserving placement metadata. Largest-first guarantees that a fitting + /// smaller asset is not discarded behind an oversized one. Equal sizes use + /// deterministic scene order. Encoded delta/reuse messages return `None`; callers + /// can invalidate that baseline and retry as a full surface. + pub(crate) fn pop_pane_surface_asset(&mut self) -> Option<SurfaceGraphicsAssetKey> { let Self::Semantic { message: ServerMessage::PaneSurface(surface), + queued_graphics_assets, .. } = self else { - return false; + return None; }; - if surface.graphics.assets.is_empty() { - return false; + let index = surface + .graphics + .assets + .iter() + .enumerate() + .max_by_key(|(index, asset)| (asset.data.len(), *index))? + .0; + let asset = surface.graphics.assets.remove(index); + let key = asset.key; + if let Some(index) = queued_graphics_assets + .iter() + .position(|queued| *queued == key) + { + queued_graphics_assets.remove(index); } - surface.graphics.assets.clear(); - true + Some(key) } } @@ -730,6 +780,31 @@ mod tests { assert!(bytes.len() < crate::protocol::MAX_FRAME_SIZE); } + #[test] + fn deferred_file_upload_keeps_identical_metadata_and_retries_without_committing() { + for reuse in [false, true] { + let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); + state.enable_surface_reuse(reuse); + let surface = popup_surface("native"); + let first = state.prepare_pane_surface(surface.clone()).unwrap(); + state.commit_sent_frame(first); + assert!(state.prepare_pane_surface(surface.clone()).is_none()); + let file = state + .prepare_pane_surface_with_file(surface.clone(), true) + .unwrap(); + let retry = state + .prepare_pane_surface_with_file(surface.clone(), true) + .unwrap(); + let config = bincode::config::standard(); + assert_eq!( + bincode::serde::encode_to_vec(file.message(), config).unwrap(), + bincode::serde::encode_to_vec(retry.message(), config).unwrap() + ); + state.commit_sent_frame(retry); + assert!(state.prepare_pane_surface(surface).is_none()); + } + } + #[test] fn popup_only_surface_changes_are_not_deduplicated() { let mut state = ClientRenderState::new(RenderEncoding::SemanticFrame); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 3a460312..ce7cab92 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -593,6 +593,11 @@ impl TerminalRuntime { #[cfg(test)] impl TerminalRuntime { + #[cfg(unix)] + pub(crate) fn test_enable_kitty_source_forwarding(&self) { + self.0.test_enable_kitty_source_forwarding(); + } + pub(crate) fn test_contend_during_dirty_collection( &self, bytes: Vec<u8>, diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index cd7f44f6..351b9560 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -170,3 +170,61 @@ just test-one clear_pane just maintenance-test just check ``` + +## 0007 experimental encoded PNG and immutable source retention + +status: active + +patch: `vendor/patches/libghostty-vt/0007-experimental-png-retention.patch` + +herdr issue: none; maintainer-directed native Kitty forwarding experiment + +upstream discussion: not opened + +upstream pr: not opened + +vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/terminal.h` +- `vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h` +- `vendor/libghostty-vt/src/terminal/c/terminal.zig` +- `vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig` +- `vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig` +- `vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig` +- `vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig` + +reason: An explicitly enabled embedding mode retains structurally validated, +quiet PNG uploads as encoded bytes, avoiding pixel decoding before forwarding +through a multiplexer. The existing raw getters retain their meaning; separate +getters expose retained PNG bytes. Queries and response-bearing uploads retain +full validation, and animation operations materialize pixels transactionally. +Storage reserves both encoded bytes and expected decoded size. + +This is default-off and experimental: CRC-valid corrupt compressed pixels may +be rejected later than in normal mode, including after placement. Quiet mode +suppresses replies, not validation semantics; this patch is not a claim of full +protocol-equivalent transparent forwarding. Herdr exercises this mode only in +tests; production PNG uploads retain full decoding and validation. + +A separate default-off snapshot callback retains host-owned immutable raw RGBA +file backing before reading pixels. Herdr installs this callback automatically +on Linux, using same-filesystem CoW snapshots. It never retains a mutable producer pathname. Unsupported snapshots +use the original loader; animation materializes pixels transactionally. Backing +ownership and bounded reads are explicit in the embedding ABI. + +remove when: upstream provides an equivalent opt-in owned encoded-image +representation and immutable host-backed raw sources with bounded storage, +strict query handling and lazy pixel materialization, or this experiment is retired. + +verification: + +```sh +just test-one native_source +just test-one png_forward_tests +just test-one kitty_png_replacement +just test-one kitty_file_image_survives +(cd vendor/libghostty-vt && zig build test-lib-vt -Dtest-filter='experimental PNG') +just check +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h index 99cee60a..8dad8086 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h @@ -391,12 +391,13 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Borrowed pointer to the raw pixel data. Valid as long as the * underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when - * the image metadata is resident but its pixel payload is pending. + * the image metadata is resident but its pixel payload is pending, or + * when the experimental PRESERVE_PNG option retained encoded PNG data. * * The data is always fully decoded, uncompressed pixels in the * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads - * are inflated and PNG payloads are decoded to RGBA at transmission - * time, before the image is stored. Consumers can upload this + * are inflated and PNG payloads are normally decoded to RGBA at transmission + * time, before the image is stored. Consumers can upload returned pixels * directly to the GPU without any decode step. * * For an animated image (Kitty graphics animation, actions a=f/a=a) @@ -412,7 +413,7 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Length of the raw pixel data in bytes. Always equal to * width * height * bytes-per-pixel for the reported format. For a - * pending image, this is the expected length reserved against the + * pending or encoded PNG image, this is the expected decoded length reserved against the * storage limit even though DATA_PTR is not available yet. * * Output type: size_t * @@ -439,6 +440,17 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9, + /** Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE + * unless experimentally retained. Output type: const uint8_t **. + */ + GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR = 10, + /** Encoded PNG length; NO_VALUE unless retained. Output type: size_t *. */ + GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN = 11, + /** Borrowed void* backing context; valid only while image is unchanged. */ + GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT = 12, + /** uint64_t immutable revision fingerprint; never reads image pixels. */ + GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY = 13, + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsImageData; diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index 81f9ebd0..6404af53 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h +++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -27,6 +27,39 @@ extern "C" { #endif +/** Borrowed opened descriptor; size is sizeof this request. No path is supplied. */ +typedef struct { + size_t size; + int64_t fd; + uint64_t expected_len; +} GhosttyKittyImageSnapshotFileRequest; + +typedef bool (*GhosttyKittyImageFileReadFn)(void *context, uint8_t *dest, size_t len); +typedef void (*GhosttyKittyImageFileReleaseFn)(void *context); + +typedef struct { + void *context; + uint64_t identity; + size_t len; + GhosttyKittyImageFileReadFn read; + GhosttyKittyImageFileReleaseFn release; +} GhosttyKittyImageFileBacking; + +/** Optional synchronous callback, default NULL. Verify exact descriptor size + * and snapshot immutable bytes before returning true. True transfers one context + * reference; false transfers nothing. Do not close the borrowed descriptor or + * reenter the terminal. Both read and release MUST be non-NULL on success. + * Read must fill exactly len bytes; release is called once. An incomplete + * backing is rejected and ordinary file loading is used instead. If release + * is provided, rejection calls it once; without release, the host remains + * responsible for cleanup (the terminal cannot release that context). + * identity identifies the immutable revision, not a pathname or source inode. + */ +typedef bool (*GhosttyKittyImageSnapshotFileFn)( + GhosttyTerminal terminal, void *userdata, + const GhosttyKittyImageSnapshotFileRequest *request, + GhosttyKittyImageFileBacking *out_backing); + /** @defgroup terminal Terminal * * Complete terminal emulator state and rendering. @@ -1545,6 +1578,14 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: size_t* */ GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39, + + /** Experimental, default-off PNG retention for quiet base transmissions. + * Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only + * be detected when an animation operation materializes the image. + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40, + /** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig index 2bd5e862..c66b13be 100644 --- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig @@ -183,6 +183,10 @@ pub const ImageData = enum(c_int) { data_ptr = 7, data_len = 8, generation = 9, + encoded_png_ptr = 10, + encoded_png_len = 11, + file_context = 12, + file_identity = 13, pub fn OutType(comptime self: ImageData) type { return switch (self) { @@ -190,9 +194,10 @@ pub const ImageData = enum(c_int) { .id, .number, .width, .height => u32, .format => ImageFormat, .compression => ImageCompression, - .data_ptr => [*]const u8, - .data_len => usize, - .generation => u64, + .data_ptr, .encoded_png_ptr => [*]const u8, + .data_len, .encoded_png_len => usize, + .generation, .file_identity => u64, + .file_context => ?*anyopaque, }; } }; @@ -266,7 +271,26 @@ fn imageGetTyped( // data; the image generation changes whenever the current // frame does, so generation-keyed caches stay coherent. .data_ptr => out.* = (image.renderData().bytes() orelse return .no_value).ptr, - .data_len => out.* = image.renderData().len(), + .data_len => out.* = switch (image.renderData()) { + .encoded_png => |png| png.decoded_len, + else => image.renderData().len(), + }, + .encoded_png_ptr => out.* = switch (image.data) { + .encoded_png => |png| png.bytes.ptr, + else => return .no_value, + }, + .encoded_png_len => out.* = switch (image.data) { + .encoded_png => |png| png.bytes.len, + else => return .no_value, + }, + .file_context => out.* = switch (image.data) { + .native_file => |file| file.context, + else => return .no_value, + }, + .file_identity => out.* = switch (image.data) { + .native_file => |file| file.identity, + else => return .no_value, + }, .generation => out.* = image.generation, } diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index 91bbe310..e825183e 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -13,6 +13,7 @@ const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); const apc = @import("../apc.zig"); const kitty = @import("../kitty/key.zig"); +const kitty_image = @import("../kitty/graphics_image.zig"); const kitty_gfx_c = @import("kitty_graphics.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); @@ -257,6 +258,7 @@ const Effects = struct { clipboard_write: ?ClipboardWriteFn = null, clipboard_read: ?ClipboardReadFn = null, unknown_sequence: ?UnknownSequenceFn = null, + snapshot_file: ?SnapshotFileFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -265,6 +267,14 @@ const Effects = struct { /// (`reportDeviceAttributes`) reads it before any re-entrant call. da_features_buf: [64]device_attributes.Primary.Feature = undefined, + pub const SnapshotFileFn = *const fn (Terminal, ?*anyopaque, *const kitty_image.SnapshotFileRequest, *kitty_image.FileBacking) callconv(lib.calling_conv) bool; + + fn snapshotFile(context: ?*anyopaque, request: *const kitty_image.SnapshotFileRequest, out: *kitty_image.FileBacking) bool { + const wrapper: *TerminalWrapper = @ptrCast(@alignCast(context.?)); + const callback = wrapper.effects.snapshot_file orelse return false; + return callback(@ptrCast(wrapper), wrapper.effects.userdata, request, out); + } + /// C function pointer type for the write_pty callback. pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void; @@ -1174,12 +1184,15 @@ pub const Option = enum(c_int) { terminfo_name = 37, clipboard_read = 38, clipboard_write_max_bytes = 39, + kitty_image_preserve_png = 40, + kitty_image_snapshot_file = 41, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { return switch (self) { .userdata => ?*const anyopaque, .write_pty => ?Effects.WritePtyFn, + .kitty_image_snapshot_file => ?Effects.SnapshotFileFn, .bell => ?Effects.BellFn, .color_scheme => ?Effects.ColorSchemeFn, .desktop_notification => ?Effects.DesktopNotificationFn, @@ -1197,6 +1210,7 @@ pub const Option = enum(c_int) { .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, .kitty_image_storage_limit => ?*const u64, + .kitty_image_preserve_png, .kitty_image_medium_file, .kitty_image_medium_shared_mem, .glyph_protocol, @@ -1250,6 +1264,17 @@ fn setTyped( switch (option) { .userdata => wrapper.effects.userdata = @constCast(value), .write_pty => wrapper.effects.write_pty = value, + .kitty_image_snapshot_file => { + if (comptime !build_options.kitty_graphics) return .success; + wrapper.effects.snapshot_file = value; + var it = wrapper.terminal.screens.all.iterator(); + while (it.next()) |entry| { + entry.value.*.kitty_images.image_limits.snapshot_file = if (value != null) + .{ .context = @ptrCast(wrapper), .callback = &Effects.snapshotFile } + else + null; + } + }, .bell => wrapper.effects.bell = value, .color_scheme => wrapper.effects.color_scheme = value, .desktop_notification => wrapper.effects.desktop_notification = value, @@ -1330,6 +1355,7 @@ fn setTyped( screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit); } }, + .kitty_image_preserve_png, .kitty_image_medium_file, .kitty_image_medium_shared_mem, => { @@ -1339,6 +1365,7 @@ fn setTyped( while (it.next()) |entry| { const screen = entry.value.*; switch (option) { + .kitty_image_preserve_png => screen.kitty_images.image_limits.preserve_png = val, .kitty_image_medium_file => screen.kitty_images.image_limits.file = val, .kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val, else => unreachable, diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig index b1b968b2..203190f1 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig @@ -509,6 +509,11 @@ fn transmitAnimationFrame( result.message = "ENOENT: image not found"; return result; }; + storage.materializeBacking(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + result.id = img.id; var loading = LoadingImage.init( @@ -773,6 +778,10 @@ fn controlAnimation( result.message = "ENOENT: image not found"; return result; }; + storage.materializeBacking(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; const anim = ensureAnimation(alloc, img) catch |err| { encodeError(&result, err); @@ -857,6 +866,11 @@ fn composeAnimation( result.message = "ENOENT: image not found"; return result; }; + storage.materializeBacking(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + result.id = img.id; if (img.data.bytes() == null) { result.message = "EINVAL: image data incomplete"; @@ -1058,7 +1072,10 @@ fn loadAndAddImage( // loading.debugDump() catch unreachable; // Validate and store our image - var img = try loading.complete(alloc); + var img = if (storage.image_limits.preserve_png) + try loading.completePreservingPng(alloc) + else + try loading.complete(alloc); errdefer img.deinit(alloc); try storage.addImage(io, alloc, terminal.screens.active, img); @@ -3601,3 +3618,87 @@ test "kittygfx animation: control negative gap makes frame gapless" { const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); } + +test "kittygfx experimental PNG retention quiet inheritance and transactional materialization" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + const sys = @import("../sys.zig"); + const Decoder = struct { + var calls: usize = 0; + var fail: bool = false; + fn decode(a: Allocator, _: []const u8) sys.DecodeError!sys.Image { + calls += 1; + if (fail) return error.InvalidData; + const pixels = try a.alloc(u8, 50 * 76 * 4); + @memset(pixels, 0); + return .{ .width = 50, .height = 76, .data = pixels }; + } + }; + const original = sys.decode_png; + defer sys.decode_png = original; + sys.decode_png = &Decoder.decode; + Decoder.calls = 0; + Decoder.fail = false; + const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + var cmd: Command = .{ + .quiet = .failures, + .control = .{ .transmit = .{ .format = .png, .image_id = 1 } }, + .data = @constCast(png), + }; + // Default off is the normal decoder path even for q=2. + try testing.expect(!storage.image_limits.preserve_png); + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(@as(usize, 1), Decoder.calls); + try testing.expect(storage.imageById(1).?.data == .complete); + storage.image_limits.preserve_png = true; + for ([_]Command.Quiet{ .no, .ok }) |quiet| { + cmd.quiet = quiet; + _ = execute(io, alloc, &t, &cmd); + try testing.expect(storage.imageById(1).?.data == .complete); + } + try testing.expectEqual(@as(usize, 3), Decoder.calls); + cmd.quiet = .failures; + cmd.control = .{ .query = .{ .format = .png, .image_id = 2 } }; + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(@as(usize, 4), Decoder.calls); + try testing.expect(storage.imageById(2) == null); + cmd.control = .{ .transmit = .{ .format = .png, .image_id = 1, .more_chunks = true } }; + cmd.data = @constCast(png[0..33]); + _ = execute(io, alloc, &t, &cmd); + cmd.quiet = .no; + cmd.control = .{ .transmit = .{} }; + cmd.data = @constCast(png[33..]); + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(@as(usize, 4), Decoder.calls); + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expectEqualSlices(u8, png, img.data.encoded_png.bytes); + try testing.expect(img.data.bytes() == null); + try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); + const generation = img.generation; + Decoder.fail = true; + const animate: Command = .{ .control = .{ .control_animation = .{ .image_id = 1 } } }; + _ = execute(io, alloc, &t, &animate); + try testing.expectEqual(generation, img.generation); + try testing.expect(img.data == .encoded_png); + try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); + Decoder.fail = false; + _ = execute(io, alloc, &t, &animate); + try testing.expect(img.generation > generation); + try testing.expect(img.data == .complete); + try testing.expectEqual(@as(usize, 50 * 76 * 4), storage.total_bytes); + + // The reservation includes both the encoded and decoded representation. + storage.setLimit(io, alloc, t.screens.active, 50 * 76 * 4); + cmd.quiet = .failures; + cmd.control = .{ .transmit = .{ .format = .png, .image_id = 3 } }; + cmd.data = @constCast(png); + const calls = Decoder.calls; + _ = execute(io, alloc, &t, &cmd); + try testing.expectEqual(calls, Decoder.calls); + try testing.expect(storage.imageById(3) == null); + try testing.expect(storage.loading == null); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig index e411bafe..c841c48d 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig @@ -21,6 +21,24 @@ const max_dimension = 10000; /// Maximum size in bytes, taken from Kitty. const max_size = 400 * 1024 * 1024; // 400MB +/// Host-owned immutable raw RGBA snapshot. A successful callback transfers one reference. +pub const FileBacking = extern struct { + context: ?*anyopaque, + identity: u64, + len: usize, + read: ?*const fn (?*anyopaque, [*]u8, usize) callconv(.c) bool, + release: ?*const fn (?*anyopaque) callconv(.c) void, +}; +pub const SnapshotFileRequest = extern struct { + size: usize = @sizeOf(SnapshotFileRequest), + fd: i64, + expected_len: u64, +}; +pub const SnapshotFileHook = struct { + context: ?*anyopaque, + callback: *const fn (?*anyopaque, *const SnapshotFileRequest, *FileBacking) bool, +}; + /// An image that is still being loaded. The image should be initialized /// using init on the first chunk and then addData for each subsequent /// chunk. Once all chunks have been added, complete should be called @@ -29,6 +47,7 @@ pub const LoadingImage = struct { /// The in-progress image. The first chunk must have all the metadata /// so this comes from that initially. image: Image, + snapshot_file: ?SnapshotFileHook = null, /// The data that is being built up. data: std.ArrayListUnmanaged(u8) = .empty, @@ -85,6 +104,8 @@ pub const LoadingImage = struct { disabled: void, }, shared_memory: bool, + preserve_png: bool = false, + snapshot_file: ?SnapshotFileHook = null, /// Enables all filesystem-related image transmission mediums. `path` /// is the temporary directory to expect files in when files are @@ -132,6 +153,10 @@ pub const LoadingImage = struct { .metadata = .{ .transient = t.usage.transient }, }, + .snapshot_file = if (cmd.control == .transmit or cmd.control == .transmit_and_display) + limits.snapshot_file + else + null, .display = cmd.display(), .quiet = cmd.quiet, .response = .{ @@ -427,6 +452,16 @@ pub const LoadingImage = struct { return error.InvalidData; } + // Only whole, uncompressed base RGBA files qualify. The host must + // verify the descriptor size and create an immutable private snapshot. + if (comptime builtin.os.tag != .windows and builtin.os.tag != .wasi and builtin.os.tag != .freestanding) { + if (self.snapshot_file) |hook| { + if (nativeFileExpectedLen(t)) |expected| { + if (try self.trySnapshotFile(hook, .{ .fd = @intCast(file.handle), .expected_len = expected })) return; + } + } + } + var buf: [4096]u8 = undefined; var buf_reader = file.reader(io, &buf); if (t.offset > 0) { @@ -468,6 +503,32 @@ pub const LoadingImage = struct { self.data = .{ .items = managed.items, .capacity = managed.capacity }; } + /// Accept only complete callback records. A malformed success falls back + /// to the ordinary reader, releasing the transferred reference if possible. + fn trySnapshotFile(self: *LoadingImage, hook: SnapshotFileHook, request: SnapshotFileRequest) !bool { + var backing: FileBacking = .{ .context = null, .identity = 0, .len = 0, .read = null, .release = null }; + if (!hook.callback(hook.context, &request, &backing)) return false; + if (backing.read == null or backing.release == null) { + if (backing.release) |release| release(backing.context); + return false; + } + if (backing.len != request.expected_len) { + backing.release.?(backing.context); + return error.InvalidData; + } + self.image.data = .{ .native_file = backing }; + return true; + } + + fn nativeFileExpectedLen(t: command.Transmission) ?usize { + if (t.medium != .file or t.format != .rgba or t.compression != .none or + t.more_chunks or t.offset != 0 or t.width == 0 or t.height == 0 or + t.width > max_dimension or t.height > max_dimension) return null; + const len = @as(usize, t.width) * @as(usize, t.height) * 4; + if (len > 16 * 1024 * 1024 or (t.size != 0 and t.size != len)) return null; + return len; + } + /// Returns the canonical path of an open file after applying the file /// transmission blocklist. fn validatedFilePath(io: std.Io, file: std.Io.File, buf: []u8) ![]const u8 { @@ -569,7 +630,7 @@ pub const LoadingImage = struct { // Data length must be what we expect. const bpp = command.Transmission.formatBpp(img.format); const expected_len = img.width * img.height * bpp; - const actual_len = self.data.items.len; + const actual_len = if (img.data == .native_file) img.data.native_file.len else self.data.items.len; if (self.frame != null) { // Kitty allows animation frames to exceed their expected length // and just truncates it. Not sure if thats expected but lets @@ -592,12 +653,37 @@ pub const LoadingImage = struct { // Everything looks good, copy the image data over. var result = self.image; - result.data = .{ .complete = try self.data.toOwnedSlice(alloc) }; + if (result.data != .native_file) result.data = .{ .complete = try self.data.toOwnedSlice(alloc) }; errdefer result.deinit(alloc); self.image = .{}; return result; } + /// Experimental forwarding path: only invoked for assembled quiet base + /// transmissions. Never used by queries or animation frame loading. + pub fn completePreservingPng(self: *LoadingImage, alloc: Allocator) !Image { + if (self.image.format != .png or self.image.compression != .none or + self.quiet != .failures or self.frame != null) return self.complete(alloc); + const header = inspectPng(self.data.items) catch |err| switch (err) { + // Do not expand the deferred-corruption contract to metadata we + // have not validated. Such PNGs keep the normal decoder semantics. + error.UnsupportedPngRetention => return self.complete(alloc), + error.InvalidData => return error.InvalidData, + error.DimensionsRequired => return error.DimensionsRequired, + error.DimensionsTooLarge => return error.DimensionsTooLarge, + }; + var result = self.image; + result.width = header.width; + result.height = header.height; + result.format = .rgba; + result.data = .{ .encoded_png = .{ + .bytes = try self.data.toOwnedSlice(alloc), + .decoded_len = header.decoded_len, + } }; + self.image = .{}; + return result; + } + /// Debug function to write the data to a file. This is useful for /// capturing some test data for unit tests. pub fn debugDump(io: std.Io, self: LoadingImage) !void { @@ -768,11 +854,16 @@ pub const Image = struct { /// Expected decoded byte length for a payload that has not arrived. pending: usize, + encoded_png: struct { bytes: []const u8, decoded_len: usize }, + native_file: FileBacking, + /// Bytes reserved against the storage limit. pub fn len(self: Data) usize { return switch (self) { .complete => |data| data.len, .pending => |expected_len| expected_len, + .native_file => |file| file.len, + .encoded_png => |png| png.bytes.len + png.decoded_len, }; } @@ -780,7 +871,7 @@ pub const Image = struct { pub fn bytes(self: Data) ?[]const u8 { return switch (self) { .complete => |data| data, - .pending => null, + .pending, .encoded_png, .native_file => null, }; } @@ -792,10 +883,43 @@ pub const Image = struct { switch (self.*) { .complete => |data| if (data.len > 0) alloc.free(data), .pending => {}, + .encoded_png => |png| alloc.free(png.bytes), + .native_file => |file| if (file.release) |release| release(file.context), } } }; + /// Transactional: a failed host read leaves the authoritative backing owned. + pub fn materializeFile(self: *Image, alloc: Allocator) !void { + const file = switch (self.data) { + .native_file => |file| file, + else => return, + }; + const read = file.read orelse return error.InvalidData; + const release = file.release orelse return error.InvalidData; + const bytes = try alloc.alloc(u8, file.len); + errdefer alloc.free(bytes); + if (!read(file.context, bytes.ptr, bytes.len)) return error.InvalidData; + self.data = .{ .complete = bytes }; + release(file.context); + } + + /// Transactional: neither ownership nor metadata changes on failure. + pub fn materializePng(self: *Image, alloc: Allocator) !void { + const png = switch (self.data) { + .encoded_png => |png| png, + else => return, + }; + const decode = sys.decode_png orelse return error.UnsupportedFormat; + var limited: LimitedAllocator = .init(alloc, max_size); + const result = try decode(limited.allocator(), png.bytes); + errdefer alloc.free(result.data); + if (result.width != self.width or result.height != self.height or + result.data.len != png.decoded_len) return error.InvalidData; + alloc.free(png.bytes); + self.data = .{ .complete = result.data }; + } + pub fn deinit(self: *Image, alloc: Allocator) void { self.data.deinit(alloc); if (self.animation) |anim| { @@ -2159,3 +2283,404 @@ test "limits: temporary file medium allowed by limits" { ); defer loading.deinit(alloc); } + +const PngHeader = struct { width: u32, height: u32, decoded_len: usize }; + +/// Bounded structural validation, deliberately without inflating IDAT. +/// CRC-valid but corrupt compressed pixels can fail later in this experiment. +fn inspectPng(bytes: []const u8) !PngHeader { + if (bytes.len > max_size or bytes.len < 33 or + !std.mem.eql(u8, bytes[0..8], "\x89PNG\r\n\x1a\n")) return error.InvalidData; + var offset: usize = 8; + var header: ?PngHeader = null; + var idat = false; + var ended_idat = false; + var palette = false; + var indexed = false; + var color_type: u8 = 0; + var bit_depth: u8 = 0; + var palette_entries: usize = 0; + var transparency = false; + while (offset < bytes.len) { + if (bytes.len - offset < 12) return error.InvalidData; + const len: usize = std.mem.readInt(u32, bytes[offset..][0..4], .big); + if (len > bytes.len - offset - 12) return error.InvalidData; + const kind = bytes[offset + 4 ..][0..4]; + for (kind) |ch| if (!std.ascii.isAlphabetic(ch)) return error.InvalidData; + if (kind[2] & 0x20 != 0) return error.InvalidData; + const body = bytes[offset + 8 ..][0..len]; + const crc = std.mem.readInt(u32, bytes[offset + 8 + len ..][0..4], .big); + if (std.hash.crc.Crc32IsoHdlc.hash(bytes[offset + 4 ..][0 .. len + 4]) != crc) + return error.InvalidData; + offset += len + 12; + if (std.mem.eql(u8, kind, "IHDR")) { + if (header != null or len != 13) return error.InvalidData; + const width = std.mem.readInt(u32, body[0..4], .big); + const height = std.mem.readInt(u32, body[4..8], .big); + if (width == 0 or height == 0) return error.DimensionsRequired; + if (width > max_dimension or height > max_dimension) return error.DimensionsTooLarge; + const depth = body[8]; + const supported = switch (body[9]) { + 0 => depth == 1 or depth == 2 or depth == 4 or depth == 8 or depth == 16, + 2, 4, 6 => depth == 8 or depth == 16, + 3 => depth == 1 or depth == 2 or depth == 4 or depth == 8, + else => false, + }; + if (!supported or body[10] != 0 or body[11] != 0 or body[12] > 1) + return error.InvalidData; + color_type = body[9]; + bit_depth = depth; + indexed = color_type == 3; + const pixels = std.math.mul(usize, width, height) catch return error.InvalidData; + const decoded_len = std.math.mul(usize, pixels, 4) catch return error.InvalidData; + if (decoded_len > max_size) return error.InvalidData; + header = .{ .width = width, .height = height, .decoded_len = decoded_len }; + } else { + if (header == null) return error.InvalidData; + if (std.mem.eql(u8, kind, "IDAT")) { + if (ended_idat or (indexed and !palette)) return error.InvalidData; + idat = true; + } else { + if (idat) ended_idat = true; + if (std.mem.eql(u8, kind, "IEND")) { + if (!idat or len != 0 or offset != bytes.len) return error.InvalidData; + return header.?; + } else if (std.mem.eql(u8, kind, "PLTE")) { + if (palette or idat or transparency or len == 0 or len > 768 or len % 3 != 0 or + color_type == 0 or color_type == 4) return error.InvalidData; + palette_entries = len / 3; + if (indexed and palette_entries > (@as(usize, 1) << @intCast(bit_depth))) + return error.InvalidData; + palette = true; + } else if (std.mem.eql(u8, kind, "tRNS")) { + if (transparency or idat) return error.InvalidData; + switch (color_type) { + 0, 2 => { + const expected: usize = if (color_type == 0) 2 else 6; + if (len != expected) return error.InvalidData; + const max_sample: u32 = (@as(u32, 1) << @intCast(bit_depth)) - 1; + var i: usize = 0; + while (i < len) : (i += 2) { + if (std.mem.readInt(u16, body[i..][0..2], .big) > max_sample) + return error.InvalidData; + } + }, + 3 => if (!palette or len == 0 or len > palette_entries) + return error.InvalidData, + // Alpha-bearing color types must not carry tRNS. + else => return error.InvalidData, + } + transparency = true; + } else if (kind[0] & 0x20 == 0) { + return error.InvalidData; + } else { + // sBIT, bKGD, color profiles, APNG, text, and any other + // ancillary extensions are deliberately not eligible. + // The strict decoder handles their validity/semantics. + return error.UnsupportedPngRetention; + } + } + } + } + return error.InvalidData; +} + +test "experimental PNG inspection validates CRC and complete structure" { + const testing = std.testing; + const original = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); + const header = try inspectPng(original); + try testing.expectEqual(@as(u32, 50), header.width); + try testing.expectEqual(@as(u32, 76), header.height); + try testing.expectEqual(@as(usize, 50 * 76 * 4), header.decoded_len); + try testing.expectError(error.InvalidData, inspectPng(original[0 .. original.len - 1])); + var corrupt = original.*; + corrupt[29] ^= 1; // IHDR CRC + try testing.expectError(error.InvalidData, inspectPng(&corrupt)); + corrupt = original.*; + corrupt[0] = 0; + try testing.expectError(error.InvalidData, inspectPng(&corrupt)); +} + +test "experimental PNG inspection transparency and palette metadata" { + const testing = std.testing; + const Chunk = struct { kind: *const [4]u8, body: []const u8 }; + const Helper = struct { + fn append(out: *std.ArrayList(u8), kind: *const [4]u8, body: []const u8) !void { + var word: [4]u8 = undefined; + std.mem.writeInt(u32, &word, @intCast(body.len), .big); + try out.appendSlice(testing.allocator, &word); + const crc_start = out.items.len; + try out.appendSlice(testing.allocator, kind); + try out.appendSlice(testing.allocator, body); + std.mem.writeInt(u32, &word, std.hash.crc.Crc32IsoHdlc.hash(out.items[crc_start..]), .big); + try out.appendSlice(testing.allocator, &word); + } + fn make(color: u8, depth: u8, chunks: []const Chunk) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(testing.allocator); + try out.appendSlice(testing.allocator, "\x89PNG\r\n\x1a\n"); + const ihdr = [_]u8{ 0, 0, 0, 1, 0, 0, 0, 1, depth, color, 0, 0, 0 }; + try append(&out, "IHDR", &ihdr); + for (chunks) |chunk| try append(&out, chunk.kind, chunk.body); + try append(&out, "IEND", ""); + return out.toOwnedSlice(testing.allocator); + } + fn check(color: u8, depth: u8, chunks: []const Chunk, expected: ?anyerror) !void { + const png = try make(color, depth, chunks); + defer testing.allocator.free(png); + if (expected) |err| { + try testing.expectError(err, inspectPng(png)); + } else { + _ = try inspectPng(png); + } + } + }; + // IDAT is intentionally opaque; this test only exercises structure. + const idat: Chunk = .{ .kind = "IDAT", .body = "" }; + const palette: Chunk = .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 255, 255, 255 } }; + const alpha: Chunk = .{ .kind = "tRNS", .body = &.{128} }; + const gray: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1 } }; + const rgb: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1, 0, 2, 0, 3 } }; + try Helper.check(3, 1, &.{ palette, alpha, idat }, null); + try Helper.check(0, 1, &.{ gray, idat }, null); + try Helper.check(2, 8, &.{ rgb, idat }, null); + try Helper.check(2, 8, &.{ palette, rgb, idat }, null); + try Helper.check(3, 1, &.{ alpha, palette, idat }, error.InvalidData); + try Helper.check(3, 1, &.{ palette, idat, alpha }, error.InvalidData); + try Helper.check(3, 1, &.{ palette, alpha, alpha, idat }, error.InvalidData); + try Helper.check(0, 8, &.{ alpha, idat }, error.InvalidData); + try Helper.check(2, 8, &.{ gray, idat }, error.InvalidData); + try Helper.check(4, 8, &.{ gray, idat }, error.InvalidData); + try Helper.check(6, 8, &.{ rgb, idat }, error.InvalidData); + try Helper.check(0, 8, &.{ palette, idat }, error.InvalidData); + try Helper.check(4, 8, &.{ palette, idat }, error.InvalidData); + try Helper.check(2, 8, &.{ rgb, palette, idat }, error.InvalidData); + try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = "" }, idat }, error.InvalidData); + try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = &.{ 1, 2, 3 } }, idat }, error.InvalidData); + try Helper.check(3, 1, &.{ .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 1, 1, 1, 2, 2, 2 } }, idat }, error.InvalidData); + try Helper.check(0, 1, &.{ .{ .kind = "tRNS", .body = &.{ 0, 2 } }, idat }, error.InvalidData); + try Helper.check(2, 8, &.{ .{ .kind = "tRNS", .body = &.{ 1, 0, 0, 0, 0, 0 } }, idat }, error.InvalidData); + try Helper.check(0, 16, &.{ .{ .kind = "tRNS", .body = &.{ 255, 255 } }, idat }, null); + for ([_]*const [4]u8{ "sBIT", "bKGD", "iCCP", "gAMA" }) |kind| { + try Helper.check(2, 8, &.{ .{ .kind = kind, .body = "" }, idat }, error.UnsupportedPngRetention); + } +} + +test "experimental PNG unsupported metadata falls back to strict decoding" { + const testing = std.testing; + const Decoder = struct { + var calls: usize = 0; + fn decode(_: Allocator, _: []const u8) sys.DecodeError!sys.Image { + calls += 1; + return error.InvalidData; + } + }; + const original_decoder = sys.decode_png; + defer sys.decode_png = original_decoder; + sys.decode_png = &Decoder.decode; + Decoder.calls = 0; + const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); + var loading: LoadingImage = .{ + .image = .{ .format = .png }, + .quiet = .failures, + .temporary_directory = null, + }; + defer loading.deinit(testing.allocator); + try loading.addData(testing.allocator, png[0..33]); + // CRC-valid ancillary chunk: inspection must delegate, not retain or + // return UnsupportedPngRetention to the caller. + var chunk = [_]u8{ 0, 0, 0, 1, 's', 'B', 'I', 'T', 8, 0, 0, 0, 0 }; + std.mem.writeInt(u32, chunk[9..13], std.hash.crc.Crc32IsoHdlc.hash(chunk[4..9]), .big); + try loading.addData(testing.allocator, &chunk); + try loading.addData(testing.allocator, png[33..]); + try testing.expectError(error.InvalidData, loading.completePreservingPng(testing.allocator)); + try testing.expectEqual(@as(usize, 1), Decoder.calls); +} + +test "native file completion and transactional lazy materialization" { + const testing = std.testing; + const Fake = struct { + reads: usize = 0, + releases: usize = 0, + fail: bool = false, + fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.reads += 1; + if (self.fail) return false; + @memset(dest[0..len], 123); + return true; + } + fn release(ctx: ?*anyopaque) callconv(.c) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.releases += 1; + } + fn backing(self: *@This(), len: usize) FileBacking { + return .{ .context = self, .identity = 7, .len = len, .read = read, .release = release }; + } + }; + var fake: Fake = .{}; + var loading: LoadingImage = .{ + .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } }, + .quiet = .no, + .temporary_directory = null, + }; + defer loading.deinit(testing.allocator); + var img = try loading.complete(testing.allocator); + defer img.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 0), fake.reads); + try testing.expectEqual(@as(usize, 4), img.data.len()); + try testing.expect(!img.data.isPending()); + try testing.expect(img.data.bytes() == null); + fake.fail = true; + try testing.expectError(error.InvalidData, img.materializeFile(testing.allocator)); + try testing.expect(img.data == .native_file); + try testing.expectEqual(@as(usize, 0), fake.releases); + fake.fail = false; + try img.materializeFile(testing.allocator); + try testing.expectEqual(@as(usize, 1), fake.releases); + try testing.expectEqualSlices(u8, &.{ 123, 123, 123, 123 }, img.data.bytes().?); + + var invalid: LoadingImage = .{ + .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(3) } }, + .quiet = .no, + .temporary_directory = null, + }; + try testing.expectError(error.InvalidData, invalid.complete(testing.allocator)); + invalid.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 2), fake.releases); + // q=1 also validates length without reading pixels. + var quiet_one: LoadingImage = .{ + .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } }, + .quiet = .ok, + .temporary_directory = null, + }; + const reads_before = fake.reads; + var quiet_img = try quiet_one.complete(testing.allocator); + quiet_one.deinit(testing.allocator); + try testing.expectEqual(reads_before, fake.reads); + quiet_img.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 3), fake.releases); + var canceled: LoadingImage = .{ + .image = .{ .data = .{ .native_file = fake.backing(4) } }, + .quiet = .failures, + .temporary_directory = null, + }; + canceled.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 4), fake.releases); +} + +test "native file eligibility is whole raw RGBA only" { + const testing = std.testing; + var t: command.Transmission = .{ .medium = .file, .format = .rgba, .width = 1, .height = 2 }; + try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t)); + t.size = 8; + try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t)); + t.size = 7; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.size = 0; + t.more_chunks = true; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.more_chunks = false; + t.offset = 1; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.offset = 0; + t.format = .rgb; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.format = .png; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.format = .rgba; + t.medium = .temporary_file; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.medium = .shared_memory; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.medium = .file; + t.compression = .zlib_deflate; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.compression = .none; + t.width = 0; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.width = 10001; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); + t.width = 10000; + t.height = 10000; + try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); +} + +test "native file snapshot rejects missing callbacks before retaining" { + const testing = std.testing; + const Fake = struct { + reads: usize = 0, + releases: usize = 0, + missing_read: bool = true, + missing_release: bool = false, + + fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.reads += 1; + @memset(dest[0..len], 42); + return true; + } + + fn release(ctx: ?*anyopaque) callconv(.c) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.releases += 1; + } + + fn snapshot(ctx: ?*anyopaque, request: *const SnapshotFileRequest, out: *FileBacking) bool { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + out.* = .{ + .context = ctx, + .identity = 1, + .len = @intCast(request.expected_len), + .read = if (self.missing_read) null else &read, + .release = if (self.missing_release) null else &release, + }; + return true; + } + }; + var fake: Fake = .{}; + const hook: SnapshotFileHook = .{ .context = &fake, .callback = &Fake.snapshot }; + const request: SnapshotFileRequest = .{ .fd = -1, .expected_len = 4 }; + var loading: LoadingImage = .{ + .image = .{ .width = 1, .height = 1, .format = .rgba }, + .quiet = .no, + .temporary_directory = null, + }; + // Missing read: transferred context is immediately released, not stored. + try testing.expect(!try loading.trySnapshotFile(hook, request)); + try testing.expect(loading.image.data != .native_file); + try testing.expectEqual(@as(usize, 1), fake.releases); + + // Missing release (or both): reject without calling a null pointer. + fake.missing_read = false; + fake.missing_release = true; + try testing.expect(!try loading.trySnapshotFile(hook, request)); + try testing.expect(loading.image.data != .native_file); + fake.missing_read = true; + try testing.expect(!try loading.trySnapshotFile(hook, request)); + try testing.expect(loading.image.data != .native_file); + try testing.expectEqual(@as(usize, 0), fake.reads); + try testing.expectEqual(@as(usize, 1), fake.releases); + + // The ordinary reader can still supply bytes after rejection. + try loading.addData(testing.allocator, &.{ 1, 2, 3, 4 }); + var img = try loading.complete(testing.allocator); + loading.deinit(testing.allocator); + try testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4 }, img.data.bytes().?); + img.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 1), fake.releases); + + // Valid callback ownership is unchanged: zero-read acceptance, one release. + fake.missing_read = false; + fake.missing_release = false; + var accepted: LoadingImage = .{ + .image = .{ .width = 1, .height = 1, .format = .rgba }, + .quiet = .no, + .temporary_directory = null, + }; + try testing.expect(try accepted.trySnapshotFile(hook, request)); + try testing.expect(accepted.image.data == .native_file); + try testing.expectEqual(@as(usize, 0), fake.reads); + accepted.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 2), fake.releases); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig index 897c8944..bd8cd424 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -154,7 +154,7 @@ pub const ImageStorage = struct { if (img.generation != self.generation) return false; const expected_len = switch (img.data) { - .complete => return false, + .complete, .encoded_png, .native_file => return false, .pending => |len| len, }; if (data.len != expected_len) return false; @@ -975,6 +975,15 @@ pub const ImageStorage = struct { img.generation = self.generation; } + pub fn materializeBacking(self: *ImageStorage, io: std.Io, alloc: Allocator, img: *Image) !void { + if (img.data != .encoded_png and img.data != .native_file) return; + const before = img.data.len(); + try img.materializeFile(alloc); + try img.materializePng(alloc); + self.total_bytes -= before - img.data.len(); + self.markImageContentChanged(io, img); + } + /// Convert a stored image's base data to RGBA in place, adjusting /// byte accounting. All animation composition happens in RGBA; /// this is called before the first composition into an image. diff --git a/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch b/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch new file mode 100644 index 00000000..e9964ab0 --- /dev/null +++ b/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch @@ -0,0 +1,1055 @@ +diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +index 99cee60a..8dad8086 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +@@ -391,12 +391,13 @@ typedef enum GHOSTTY_ENUM_TYPED { + /** + * Borrowed pointer to the raw pixel data. Valid as long as the + * underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when +- * the image metadata is resident but its pixel payload is pending. ++ * the image metadata is resident but its pixel payload is pending, or ++ * when the experimental PRESERVE_PNG option retained encoded PNG data. + * + * The data is always fully decoded, uncompressed pixels in the + * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads +- * are inflated and PNG payloads are decoded to RGBA at transmission +- * time, before the image is stored. Consumers can upload this ++ * are inflated and PNG payloads are normally decoded to RGBA at transmission ++ * time, before the image is stored. Consumers can upload returned pixels + * directly to the GPU without any decode step. + * + * For an animated image (Kitty graphics animation, actions a=f/a=a) +@@ -412,7 +413,7 @@ typedef enum GHOSTTY_ENUM_TYPED { + /** + * Length of the raw pixel data in bytes. Always equal to + * width * height * bytes-per-pixel for the reported format. For a +- * pending image, this is the expected length reserved against the ++ * pending or encoded PNG image, this is the expected decoded length reserved against the + * storage limit even though DATA_PTR is not available yet. + * + * Output type: size_t * +@@ -439,6 +440,17 @@ typedef enum GHOSTTY_ENUM_TYPED { + */ + GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9, + ++ /** Owned encoded PNG bytes, borrowed until the next mutation. NO_VALUE ++ * unless experimentally retained. Output type: const uint8_t **. ++ */ ++ GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_PTR = 10, ++ /** Encoded PNG length; NO_VALUE unless retained. Output type: size_t *. */ ++ GHOSTTY_KITTY_IMAGE_DATA_ENCODED_PNG_LEN = 11, ++ /** Borrowed void* backing context; valid only while image is unchanged. */ ++ GHOSTTY_KITTY_IMAGE_DATA_FILE_CONTEXT = 12, ++ /** uint64_t immutable revision fingerprint; never reads image pixels. */ ++ GHOSTTY_KITTY_IMAGE_DATA_FILE_IDENTITY = 13, ++ + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyKittyGraphicsImageData; + +diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +index 81f9ebd0..6404af53 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +@@ -27,6 +27,39 @@ + extern "C" { + #endif + ++/** Borrowed opened descriptor; size is sizeof this request. No path is supplied. */ ++typedef struct { ++ size_t size; ++ int64_t fd; ++ uint64_t expected_len; ++} GhosttyKittyImageSnapshotFileRequest; ++ ++typedef bool (*GhosttyKittyImageFileReadFn)(void *context, uint8_t *dest, size_t len); ++typedef void (*GhosttyKittyImageFileReleaseFn)(void *context); ++ ++typedef struct { ++ void *context; ++ uint64_t identity; ++ size_t len; ++ GhosttyKittyImageFileReadFn read; ++ GhosttyKittyImageFileReleaseFn release; ++} GhosttyKittyImageFileBacking; ++ ++/** Optional synchronous callback, default NULL. Verify exact descriptor size ++ * and snapshot immutable bytes before returning true. True transfers one context ++ * reference; false transfers nothing. Do not close the borrowed descriptor or ++ * reenter the terminal. Both read and release MUST be non-NULL on success. ++ * Read must fill exactly len bytes; release is called once. An incomplete ++ * backing is rejected and ordinary file loading is used instead. If release ++ * is provided, rejection calls it once; without release, the host remains ++ * responsible for cleanup (the terminal cannot release that context). ++ * identity identifies the immutable revision, not a pathname or source inode. ++ */ ++typedef bool (*GhosttyKittyImageSnapshotFileFn)( ++ GhosttyTerminal terminal, void *userdata, ++ const GhosttyKittyImageSnapshotFileRequest *request, ++ GhosttyKittyImageFileBacking *out_backing); ++ + /** @defgroup terminal Terminal + * + * Complete terminal emulator state and rendering. +@@ -1545,6 +1578,14 @@ typedef enum GHOSTTY_ENUM_TYPED { + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39, ++ ++ /** Experimental, default-off PNG retention for quiet base transmissions. ++ * Input type: bool*. NULL is a no-op. Corrupt compressed pixels may only ++ * be detected when an animation operation materializes the image. ++ */ ++ GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40, ++ /** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */ ++ GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41, + GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyTerminalOption; + +diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +index 2bd5e862..c66b13be 100644 +--- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig ++++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +@@ -183,6 +183,10 @@ pub const ImageData = enum(c_int) { + data_ptr = 7, + data_len = 8, + generation = 9, ++ encoded_png_ptr = 10, ++ encoded_png_len = 11, ++ file_context = 12, ++ file_identity = 13, + + pub fn OutType(comptime self: ImageData) type { + return switch (self) { +@@ -190,9 +194,10 @@ pub const ImageData = enum(c_int) { + .id, .number, .width, .height => u32, + .format => ImageFormat, + .compression => ImageCompression, +- .data_ptr => [*]const u8, +- .data_len => usize, +- .generation => u64, ++ .data_ptr, .encoded_png_ptr => [*]const u8, ++ .data_len, .encoded_png_len => usize, ++ .generation, .file_identity => u64, ++ .file_context => ?*anyopaque, + }; + } + }; +@@ -266,7 +271,26 @@ fn imageGetTyped( + // data; the image generation changes whenever the current + // frame does, so generation-keyed caches stay coherent. + .data_ptr => out.* = (image.renderData().bytes() orelse return .no_value).ptr, +- .data_len => out.* = image.renderData().len(), ++ .data_len => out.* = switch (image.renderData()) { ++ .encoded_png => |png| png.decoded_len, ++ else => image.renderData().len(), ++ }, ++ .encoded_png_ptr => out.* = switch (image.data) { ++ .encoded_png => |png| png.bytes.ptr, ++ else => return .no_value, ++ }, ++ .encoded_png_len => out.* = switch (image.data) { ++ .encoded_png => |png| png.bytes.len, ++ else => return .no_value, ++ }, ++ .file_context => out.* = switch (image.data) { ++ .native_file => |file| file.context, ++ else => return .no_value, ++ }, ++ .file_identity => out.* = switch (image.data) { ++ .native_file => |file| file.identity, ++ else => return .no_value, ++ }, + .generation => out.* = image.generation, + } + +diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig +index 91bbe310..e825183e 100644 +--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig ++++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig +@@ -13,6 +13,7 @@ const ScreenSet = @import("../ScreenSet.zig"); + const PageList = @import("../PageList.zig"); + const apc = @import("../apc.zig"); + const kitty = @import("../kitty/key.zig"); ++const kitty_image = @import("../kitty/graphics_image.zig"); + const kitty_gfx_c = @import("kitty_graphics.zig"); + const modes = @import("../modes.zig"); + const point = @import("../point.zig"); +@@ -257,6 +258,7 @@ const Effects = struct { + clipboard_write: ?ClipboardWriteFn = null, + clipboard_read: ?ClipboardReadFn = null, + unknown_sequence: ?UnknownSequenceFn = null, ++ snapshot_file: ?SnapshotFileFn = null, + + /// Scratch buffer for DA1 feature codes. The device attributes + /// trampoline converts C feature codes into this buffer and returns +@@ -265,6 +267,14 @@ const Effects = struct { + /// (`reportDeviceAttributes`) reads it before any re-entrant call. + da_features_buf: [64]device_attributes.Primary.Feature = undefined, + ++ pub const SnapshotFileFn = *const fn (Terminal, ?*anyopaque, *const kitty_image.SnapshotFileRequest, *kitty_image.FileBacking) callconv(lib.calling_conv) bool; ++ ++ fn snapshotFile(context: ?*anyopaque, request: *const kitty_image.SnapshotFileRequest, out: *kitty_image.FileBacking) bool { ++ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(context.?)); ++ const callback = wrapper.effects.snapshot_file orelse return false; ++ return callback(@ptrCast(wrapper), wrapper.effects.userdata, request, out); ++ } ++ + /// C function pointer type for the write_pty callback. + pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void; + +@@ -1174,12 +1184,15 @@ pub const Option = enum(c_int) { + terminfo_name = 37, + clipboard_read = 38, + clipboard_write_max_bytes = 39, ++ kitty_image_preserve_png = 40, ++ kitty_image_snapshot_file = 41, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*const anyopaque, + .write_pty => ?Effects.WritePtyFn, ++ .kitty_image_snapshot_file => ?Effects.SnapshotFileFn, + .bell => ?Effects.BellFn, + .color_scheme => ?Effects.ColorSchemeFn, + .desktop_notification => ?Effects.DesktopNotificationFn, +@@ -1197,6 +1210,7 @@ pub const Option = enum(c_int) { + .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, + .color_palette => ?*const color.PaletteC, + .kitty_image_storage_limit => ?*const u64, ++ .kitty_image_preserve_png, + .kitty_image_medium_file, + .kitty_image_medium_shared_mem, + .glyph_protocol, +@@ -1250,6 +1264,17 @@ fn setTyped( + switch (option) { + .userdata => wrapper.effects.userdata = @constCast(value), + .write_pty => wrapper.effects.write_pty = value, ++ .kitty_image_snapshot_file => { ++ if (comptime !build_options.kitty_graphics) return .success; ++ wrapper.effects.snapshot_file = value; ++ var it = wrapper.terminal.screens.all.iterator(); ++ while (it.next()) |entry| { ++ entry.value.*.kitty_images.image_limits.snapshot_file = if (value != null) ++ .{ .context = @ptrCast(wrapper), .callback = &Effects.snapshotFile } ++ else ++ null; ++ } ++ }, + .bell => wrapper.effects.bell = value, + .color_scheme => wrapper.effects.color_scheme = value, + .desktop_notification => wrapper.effects.desktop_notification = value, +@@ -1330,6 +1355,7 @@ fn setTyped( + screen.kitty_images.setLimit(screen.io, screen.alloc, screen, limit); + } + }, ++ .kitty_image_preserve_png, + .kitty_image_medium_file, + .kitty_image_medium_shared_mem, + => { +@@ -1339,6 +1365,7 @@ fn setTyped( + while (it.next()) |entry| { + const screen = entry.value.*; + switch (option) { ++ .kitty_image_preserve_png => screen.kitty_images.image_limits.preserve_png = val, + .kitty_image_medium_file => screen.kitty_images.image_limits.file = val, + .kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val, + else => unreachable, +diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +index b1b968b2..203190f1 100644 +--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig ++++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +@@ -509,6 +509,11 @@ fn transmitAnimationFrame( + result.message = "ENOENT: image not found"; + return result; + }; ++ storage.materializeBacking(io, alloc, img) catch |err| { ++ encodeError(&result, err); ++ return result; ++ }; ++ + result.id = img.id; + + var loading = LoadingImage.init( +@@ -773,6 +778,10 @@ fn controlAnimation( + result.message = "ENOENT: image not found"; + return result; + }; ++ storage.materializeBacking(io, alloc, img) catch |err| { ++ encodeError(&result, err); ++ return result; ++ }; + + const anim = ensureAnimation(alloc, img) catch |err| { + encodeError(&result, err); +@@ -857,6 +866,11 @@ fn composeAnimation( + result.message = "ENOENT: image not found"; + return result; + }; ++ storage.materializeBacking(io, alloc, img) catch |err| { ++ encodeError(&result, err); ++ return result; ++ }; ++ + result.id = img.id; + if (img.data.bytes() == null) { + result.message = "EINVAL: image data incomplete"; +@@ -1058,7 +1072,10 @@ fn loadAndAddImage( + // loading.debugDump() catch unreachable; + + // Validate and store our image +- var img = try loading.complete(alloc); ++ var img = if (storage.image_limits.preserve_png) ++ try loading.completePreservingPng(alloc) ++ else ++ try loading.complete(alloc); + errdefer img.deinit(alloc); + try storage.addImage(io, alloc, terminal.screens.active, img); + +@@ -3601,3 +3618,87 @@ test "kittygfx animation: control negative gap makes frame gapless" { + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); + } ++ ++test "kittygfx experimental PNG retention quiet inheritance and transactional materialization" { ++ const testing = std.testing; ++ const alloc = testing.allocator; ++ const io = testing.io; ++ const sys = @import("../sys.zig"); ++ const Decoder = struct { ++ var calls: usize = 0; ++ var fail: bool = false; ++ fn decode(a: Allocator, _: []const u8) sys.DecodeError!sys.Image { ++ calls += 1; ++ if (fail) return error.InvalidData; ++ const pixels = try a.alloc(u8, 50 * 76 * 4); ++ @memset(pixels, 0); ++ return .{ .width = 50, .height = 76, .data = pixels }; ++ } ++ }; ++ const original = sys.decode_png; ++ defer sys.decode_png = original; ++ sys.decode_png = &Decoder.decode; ++ Decoder.calls = 0; ++ Decoder.fail = false; ++ const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); ++ var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); ++ defer t.deinit(alloc); ++ const storage = &t.screens.active.kitty_images; ++ var cmd: Command = .{ ++ .quiet = .failures, ++ .control = .{ .transmit = .{ .format = .png, .image_id = 1 } }, ++ .data = @constCast(png), ++ }; ++ // Default off is the normal decoder path even for q=2. ++ try testing.expect(!storage.image_limits.preserve_png); ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(@as(usize, 1), Decoder.calls); ++ try testing.expect(storage.imageById(1).?.data == .complete); ++ storage.image_limits.preserve_png = true; ++ for ([_]Command.Quiet{ .no, .ok }) |quiet| { ++ cmd.quiet = quiet; ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expect(storage.imageById(1).?.data == .complete); ++ } ++ try testing.expectEqual(@as(usize, 3), Decoder.calls); ++ cmd.quiet = .failures; ++ cmd.control = .{ .query = .{ .format = .png, .image_id = 2 } }; ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(@as(usize, 4), Decoder.calls); ++ try testing.expect(storage.imageById(2) == null); ++ cmd.control = .{ .transmit = .{ .format = .png, .image_id = 1, .more_chunks = true } }; ++ cmd.data = @constCast(png[0..33]); ++ _ = execute(io, alloc, &t, &cmd); ++ cmd.quiet = .no; ++ cmd.control = .{ .transmit = .{} }; ++ cmd.data = @constCast(png[33..]); ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(@as(usize, 4), Decoder.calls); ++ const img = storage.imagePtrByIdOrNumber(1, 0).?; ++ try testing.expectEqualSlices(u8, png, img.data.encoded_png.bytes); ++ try testing.expect(img.data.bytes() == null); ++ try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); ++ const generation = img.generation; ++ Decoder.fail = true; ++ const animate: Command = .{ .control = .{ .control_animation = .{ .image_id = 1 } } }; ++ _ = execute(io, alloc, &t, &animate); ++ try testing.expectEqual(generation, img.generation); ++ try testing.expect(img.data == .encoded_png); ++ try testing.expectEqual(png.len + 50 * 76 * 4, storage.total_bytes); ++ Decoder.fail = false; ++ _ = execute(io, alloc, &t, &animate); ++ try testing.expect(img.generation > generation); ++ try testing.expect(img.data == .complete); ++ try testing.expectEqual(@as(usize, 50 * 76 * 4), storage.total_bytes); ++ ++ // The reservation includes both the encoded and decoded representation. ++ storage.setLimit(io, alloc, t.screens.active, 50 * 76 * 4); ++ cmd.quiet = .failures; ++ cmd.control = .{ .transmit = .{ .format = .png, .image_id = 3 } }; ++ cmd.data = @constCast(png); ++ const calls = Decoder.calls; ++ _ = execute(io, alloc, &t, &cmd); ++ try testing.expectEqual(calls, Decoder.calls); ++ try testing.expect(storage.imageById(3) == null); ++ try testing.expect(storage.loading == null); ++} +diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig +index e411bafe..c841c48d 100644 +--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig ++++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig +@@ -21,6 +21,24 @@ const max_dimension = 10000; + /// Maximum size in bytes, taken from Kitty. + const max_size = 400 * 1024 * 1024; // 400MB + ++/// Host-owned immutable raw RGBA snapshot. A successful callback transfers one reference. ++pub const FileBacking = extern struct { ++ context: ?*anyopaque, ++ identity: u64, ++ len: usize, ++ read: ?*const fn (?*anyopaque, [*]u8, usize) callconv(.c) bool, ++ release: ?*const fn (?*anyopaque) callconv(.c) void, ++}; ++pub const SnapshotFileRequest = extern struct { ++ size: usize = @sizeOf(SnapshotFileRequest), ++ fd: i64, ++ expected_len: u64, ++}; ++pub const SnapshotFileHook = struct { ++ context: ?*anyopaque, ++ callback: *const fn (?*anyopaque, *const SnapshotFileRequest, *FileBacking) bool, ++}; ++ + /// An image that is still being loaded. The image should be initialized + /// using init on the first chunk and then addData for each subsequent + /// chunk. Once all chunks have been added, complete should be called +@@ -29,6 +47,7 @@ pub const LoadingImage = struct { + /// The in-progress image. The first chunk must have all the metadata + /// so this comes from that initially. + image: Image, ++ snapshot_file: ?SnapshotFileHook = null, + + /// The data that is being built up. + data: std.ArrayListUnmanaged(u8) = .empty, +@@ -85,6 +104,8 @@ pub const LoadingImage = struct { + disabled: void, + }, + shared_memory: bool, ++ preserve_png: bool = false, ++ snapshot_file: ?SnapshotFileHook = null, + + /// Enables all filesystem-related image transmission mediums. `path` + /// is the temporary directory to expect files in when files are +@@ -132,6 +153,10 @@ pub const LoadingImage = struct { + .metadata = .{ .transient = t.usage.transient }, + }, + ++ .snapshot_file = if (cmd.control == .transmit or cmd.control == .transmit_and_display) ++ limits.snapshot_file ++ else ++ null, + .display = cmd.display(), + .quiet = cmd.quiet, + .response = .{ +@@ -427,6 +452,16 @@ pub const LoadingImage = struct { + return error.InvalidData; + } + ++ // Only whole, uncompressed base RGBA files qualify. The host must ++ // verify the descriptor size and create an immutable private snapshot. ++ if (comptime builtin.os.tag != .windows and builtin.os.tag != .wasi and builtin.os.tag != .freestanding) { ++ if (self.snapshot_file) |hook| { ++ if (nativeFileExpectedLen(t)) |expected| { ++ if (try self.trySnapshotFile(hook, .{ .fd = @intCast(file.handle), .expected_len = expected })) return; ++ } ++ } ++ } ++ + var buf: [4096]u8 = undefined; + var buf_reader = file.reader(io, &buf); + if (t.offset > 0) { +@@ -468,6 +503,32 @@ pub const LoadingImage = struct { + self.data = .{ .items = managed.items, .capacity = managed.capacity }; + } + ++ /// Accept only complete callback records. A malformed success falls back ++ /// to the ordinary reader, releasing the transferred reference if possible. ++ fn trySnapshotFile(self: *LoadingImage, hook: SnapshotFileHook, request: SnapshotFileRequest) !bool { ++ var backing: FileBacking = .{ .context = null, .identity = 0, .len = 0, .read = null, .release = null }; ++ if (!hook.callback(hook.context, &request, &backing)) return false; ++ if (backing.read == null or backing.release == null) { ++ if (backing.release) |release| release(backing.context); ++ return false; ++ } ++ if (backing.len != request.expected_len) { ++ backing.release.?(backing.context); ++ return error.InvalidData; ++ } ++ self.image.data = .{ .native_file = backing }; ++ return true; ++ } ++ ++ fn nativeFileExpectedLen(t: command.Transmission) ?usize { ++ if (t.medium != .file or t.format != .rgba or t.compression != .none or ++ t.more_chunks or t.offset != 0 or t.width == 0 or t.height == 0 or ++ t.width > max_dimension or t.height > max_dimension) return null; ++ const len = @as(usize, t.width) * @as(usize, t.height) * 4; ++ if (len > 16 * 1024 * 1024 or (t.size != 0 and t.size != len)) return null; ++ return len; ++ } ++ + /// Returns the canonical path of an open file after applying the file + /// transmission blocklist. + fn validatedFilePath(io: std.Io, file: std.Io.File, buf: []u8) ![]const u8 { +@@ -569,7 +630,7 @@ pub const LoadingImage = struct { + // Data length must be what we expect. + const bpp = command.Transmission.formatBpp(img.format); + const expected_len = img.width * img.height * bpp; +- const actual_len = self.data.items.len; ++ const actual_len = if (img.data == .native_file) img.data.native_file.len else self.data.items.len; + if (self.frame != null) { + // Kitty allows animation frames to exceed their expected length + // and just truncates it. Not sure if thats expected but lets +@@ -592,12 +653,37 @@ pub const LoadingImage = struct { + + // Everything looks good, copy the image data over. + var result = self.image; +- result.data = .{ .complete = try self.data.toOwnedSlice(alloc) }; ++ if (result.data != .native_file) result.data = .{ .complete = try self.data.toOwnedSlice(alloc) }; + errdefer result.deinit(alloc); + self.image = .{}; + return result; + } + ++ /// Experimental forwarding path: only invoked for assembled quiet base ++ /// transmissions. Never used by queries or animation frame loading. ++ pub fn completePreservingPng(self: *LoadingImage, alloc: Allocator) !Image { ++ if (self.image.format != .png or self.image.compression != .none or ++ self.quiet != .failures or self.frame != null) return self.complete(alloc); ++ const header = inspectPng(self.data.items) catch |err| switch (err) { ++ // Do not expand the deferred-corruption contract to metadata we ++ // have not validated. Such PNGs keep the normal decoder semantics. ++ error.UnsupportedPngRetention => return self.complete(alloc), ++ error.InvalidData => return error.InvalidData, ++ error.DimensionsRequired => return error.DimensionsRequired, ++ error.DimensionsTooLarge => return error.DimensionsTooLarge, ++ }; ++ var result = self.image; ++ result.width = header.width; ++ result.height = header.height; ++ result.format = .rgba; ++ result.data = .{ .encoded_png = .{ ++ .bytes = try self.data.toOwnedSlice(alloc), ++ .decoded_len = header.decoded_len, ++ } }; ++ self.image = .{}; ++ return result; ++ } ++ + /// Debug function to write the data to a file. This is useful for + /// capturing some test data for unit tests. + pub fn debugDump(io: std.Io, self: LoadingImage) !void { +@@ -768,11 +854,16 @@ pub const Image = struct { + /// Expected decoded byte length for a payload that has not arrived. + pending: usize, + ++ encoded_png: struct { bytes: []const u8, decoded_len: usize }, ++ native_file: FileBacking, ++ + /// Bytes reserved against the storage limit. + pub fn len(self: Data) usize { + return switch (self) { + .complete => |data| data.len, + .pending => |expected_len| expected_len, ++ .native_file => |file| file.len, ++ .encoded_png => |png| png.bytes.len + png.decoded_len, + }; + } + +@@ -780,7 +871,7 @@ pub const Image = struct { + pub fn bytes(self: Data) ?[]const u8 { + return switch (self) { + .complete => |data| data, +- .pending => null, ++ .pending, .encoded_png, .native_file => null, + }; + } + +@@ -792,10 +883,43 @@ pub const Image = struct { + switch (self.*) { + .complete => |data| if (data.len > 0) alloc.free(data), + .pending => {}, ++ .encoded_png => |png| alloc.free(png.bytes), ++ .native_file => |file| if (file.release) |release| release(file.context), + } + } + }; + ++ /// Transactional: a failed host read leaves the authoritative backing owned. ++ pub fn materializeFile(self: *Image, alloc: Allocator) !void { ++ const file = switch (self.data) { ++ .native_file => |file| file, ++ else => return, ++ }; ++ const read = file.read orelse return error.InvalidData; ++ const release = file.release orelse return error.InvalidData; ++ const bytes = try alloc.alloc(u8, file.len); ++ errdefer alloc.free(bytes); ++ if (!read(file.context, bytes.ptr, bytes.len)) return error.InvalidData; ++ self.data = .{ .complete = bytes }; ++ release(file.context); ++ } ++ ++ /// Transactional: neither ownership nor metadata changes on failure. ++ pub fn materializePng(self: *Image, alloc: Allocator) !void { ++ const png = switch (self.data) { ++ .encoded_png => |png| png, ++ else => return, ++ }; ++ const decode = sys.decode_png orelse return error.UnsupportedFormat; ++ var limited: LimitedAllocator = .init(alloc, max_size); ++ const result = try decode(limited.allocator(), png.bytes); ++ errdefer alloc.free(result.data); ++ if (result.width != self.width or result.height != self.height or ++ result.data.len != png.decoded_len) return error.InvalidData; ++ alloc.free(png.bytes); ++ self.data = .{ .complete = result.data }; ++ } ++ + pub fn deinit(self: *Image, alloc: Allocator) void { + self.data.deinit(alloc); + if (self.animation) |anim| { +@@ -2159,3 +2283,404 @@ test "limits: temporary file medium allowed by limits" { + ); + defer loading.deinit(alloc); + } ++ ++const PngHeader = struct { width: u32, height: u32, decoded_len: usize }; ++ ++/// Bounded structural validation, deliberately without inflating IDAT. ++/// CRC-valid but corrupt compressed pixels can fail later in this experiment. ++fn inspectPng(bytes: []const u8) !PngHeader { ++ if (bytes.len > max_size or bytes.len < 33 or ++ !std.mem.eql(u8, bytes[0..8], "\x89PNG\r\n\x1a\n")) return error.InvalidData; ++ var offset: usize = 8; ++ var header: ?PngHeader = null; ++ var idat = false; ++ var ended_idat = false; ++ var palette = false; ++ var indexed = false; ++ var color_type: u8 = 0; ++ var bit_depth: u8 = 0; ++ var palette_entries: usize = 0; ++ var transparency = false; ++ while (offset < bytes.len) { ++ if (bytes.len - offset < 12) return error.InvalidData; ++ const len: usize = std.mem.readInt(u32, bytes[offset..][0..4], .big); ++ if (len > bytes.len - offset - 12) return error.InvalidData; ++ const kind = bytes[offset + 4 ..][0..4]; ++ for (kind) |ch| if (!std.ascii.isAlphabetic(ch)) return error.InvalidData; ++ if (kind[2] & 0x20 != 0) return error.InvalidData; ++ const body = bytes[offset + 8 ..][0..len]; ++ const crc = std.mem.readInt(u32, bytes[offset + 8 + len ..][0..4], .big); ++ if (std.hash.crc.Crc32IsoHdlc.hash(bytes[offset + 4 ..][0 .. len + 4]) != crc) ++ return error.InvalidData; ++ offset += len + 12; ++ if (std.mem.eql(u8, kind, "IHDR")) { ++ if (header != null or len != 13) return error.InvalidData; ++ const width = std.mem.readInt(u32, body[0..4], .big); ++ const height = std.mem.readInt(u32, body[4..8], .big); ++ if (width == 0 or height == 0) return error.DimensionsRequired; ++ if (width > max_dimension or height > max_dimension) return error.DimensionsTooLarge; ++ const depth = body[8]; ++ const supported = switch (body[9]) { ++ 0 => depth == 1 or depth == 2 or depth == 4 or depth == 8 or depth == 16, ++ 2, 4, 6 => depth == 8 or depth == 16, ++ 3 => depth == 1 or depth == 2 or depth == 4 or depth == 8, ++ else => false, ++ }; ++ if (!supported or body[10] != 0 or body[11] != 0 or body[12] > 1) ++ return error.InvalidData; ++ color_type = body[9]; ++ bit_depth = depth; ++ indexed = color_type == 3; ++ const pixels = std.math.mul(usize, width, height) catch return error.InvalidData; ++ const decoded_len = std.math.mul(usize, pixels, 4) catch return error.InvalidData; ++ if (decoded_len > max_size) return error.InvalidData; ++ header = .{ .width = width, .height = height, .decoded_len = decoded_len }; ++ } else { ++ if (header == null) return error.InvalidData; ++ if (std.mem.eql(u8, kind, "IDAT")) { ++ if (ended_idat or (indexed and !palette)) return error.InvalidData; ++ idat = true; ++ } else { ++ if (idat) ended_idat = true; ++ if (std.mem.eql(u8, kind, "IEND")) { ++ if (!idat or len != 0 or offset != bytes.len) return error.InvalidData; ++ return header.?; ++ } else if (std.mem.eql(u8, kind, "PLTE")) { ++ if (palette or idat or transparency or len == 0 or len > 768 or len % 3 != 0 or ++ color_type == 0 or color_type == 4) return error.InvalidData; ++ palette_entries = len / 3; ++ if (indexed and palette_entries > (@as(usize, 1) << @intCast(bit_depth))) ++ return error.InvalidData; ++ palette = true; ++ } else if (std.mem.eql(u8, kind, "tRNS")) { ++ if (transparency or idat) return error.InvalidData; ++ switch (color_type) { ++ 0, 2 => { ++ const expected: usize = if (color_type == 0) 2 else 6; ++ if (len != expected) return error.InvalidData; ++ const max_sample: u32 = (@as(u32, 1) << @intCast(bit_depth)) - 1; ++ var i: usize = 0; ++ while (i < len) : (i += 2) { ++ if (std.mem.readInt(u16, body[i..][0..2], .big) > max_sample) ++ return error.InvalidData; ++ } ++ }, ++ 3 => if (!palette or len == 0 or len > palette_entries) ++ return error.InvalidData, ++ // Alpha-bearing color types must not carry tRNS. ++ else => return error.InvalidData, ++ } ++ transparency = true; ++ } else if (kind[0] & 0x20 == 0) { ++ return error.InvalidData; ++ } else { ++ // sBIT, bKGD, color profiles, APNG, text, and any other ++ // ancillary extensions are deliberately not eligible. ++ // The strict decoder handles their validity/semantics. ++ return error.UnsupportedPngRetention; ++ } ++ } ++ } ++ } ++ return error.InvalidData; ++} ++ ++test "experimental PNG inspection validates CRC and complete structure" { ++ const testing = std.testing; ++ const original = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); ++ const header = try inspectPng(original); ++ try testing.expectEqual(@as(u32, 50), header.width); ++ try testing.expectEqual(@as(u32, 76), header.height); ++ try testing.expectEqual(@as(usize, 50 * 76 * 4), header.decoded_len); ++ try testing.expectError(error.InvalidData, inspectPng(original[0 .. original.len - 1])); ++ var corrupt = original.*; ++ corrupt[29] ^= 1; // IHDR CRC ++ try testing.expectError(error.InvalidData, inspectPng(&corrupt)); ++ corrupt = original.*; ++ corrupt[0] = 0; ++ try testing.expectError(error.InvalidData, inspectPng(&corrupt)); ++} ++ ++test "experimental PNG inspection transparency and palette metadata" { ++ const testing = std.testing; ++ const Chunk = struct { kind: *const [4]u8, body: []const u8 }; ++ const Helper = struct { ++ fn append(out: *std.ArrayList(u8), kind: *const [4]u8, body: []const u8) !void { ++ var word: [4]u8 = undefined; ++ std.mem.writeInt(u32, &word, @intCast(body.len), .big); ++ try out.appendSlice(testing.allocator, &word); ++ const crc_start = out.items.len; ++ try out.appendSlice(testing.allocator, kind); ++ try out.appendSlice(testing.allocator, body); ++ std.mem.writeInt(u32, &word, std.hash.crc.Crc32IsoHdlc.hash(out.items[crc_start..]), .big); ++ try out.appendSlice(testing.allocator, &word); ++ } ++ fn make(color: u8, depth: u8, chunks: []const Chunk) ![]u8 { ++ var out: std.ArrayList(u8) = .empty; ++ errdefer out.deinit(testing.allocator); ++ try out.appendSlice(testing.allocator, "\x89PNG\r\n\x1a\n"); ++ const ihdr = [_]u8{ 0, 0, 0, 1, 0, 0, 0, 1, depth, color, 0, 0, 0 }; ++ try append(&out, "IHDR", &ihdr); ++ for (chunks) |chunk| try append(&out, chunk.kind, chunk.body); ++ try append(&out, "IEND", ""); ++ return out.toOwnedSlice(testing.allocator); ++ } ++ fn check(color: u8, depth: u8, chunks: []const Chunk, expected: ?anyerror) !void { ++ const png = try make(color, depth, chunks); ++ defer testing.allocator.free(png); ++ if (expected) |err| { ++ try testing.expectError(err, inspectPng(png)); ++ } else { ++ _ = try inspectPng(png); ++ } ++ } ++ }; ++ // IDAT is intentionally opaque; this test only exercises structure. ++ const idat: Chunk = .{ .kind = "IDAT", .body = "" }; ++ const palette: Chunk = .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 255, 255, 255 } }; ++ const alpha: Chunk = .{ .kind = "tRNS", .body = &.{128} }; ++ const gray: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1 } }; ++ const rgb: Chunk = .{ .kind = "tRNS", .body = &.{ 0, 1, 0, 2, 0, 3 } }; ++ try Helper.check(3, 1, &.{ palette, alpha, idat }, null); ++ try Helper.check(0, 1, &.{ gray, idat }, null); ++ try Helper.check(2, 8, &.{ rgb, idat }, null); ++ try Helper.check(2, 8, &.{ palette, rgb, idat }, null); ++ try Helper.check(3, 1, &.{ alpha, palette, idat }, error.InvalidData); ++ try Helper.check(3, 1, &.{ palette, idat, alpha }, error.InvalidData); ++ try Helper.check(3, 1, &.{ palette, alpha, alpha, idat }, error.InvalidData); ++ try Helper.check(0, 8, &.{ alpha, idat }, error.InvalidData); ++ try Helper.check(2, 8, &.{ gray, idat }, error.InvalidData); ++ try Helper.check(4, 8, &.{ gray, idat }, error.InvalidData); ++ try Helper.check(6, 8, &.{ rgb, idat }, error.InvalidData); ++ try Helper.check(0, 8, &.{ palette, idat }, error.InvalidData); ++ try Helper.check(4, 8, &.{ palette, idat }, error.InvalidData); ++ try Helper.check(2, 8, &.{ rgb, palette, idat }, error.InvalidData); ++ try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = "" }, idat }, error.InvalidData); ++ try Helper.check(3, 1, &.{ palette, .{ .kind = "tRNS", .body = &.{ 1, 2, 3 } }, idat }, error.InvalidData); ++ try Helper.check(3, 1, &.{ .{ .kind = "PLTE", .body = &.{ 0, 0, 0, 1, 1, 1, 2, 2, 2 } }, idat }, error.InvalidData); ++ try Helper.check(0, 1, &.{ .{ .kind = "tRNS", .body = &.{ 0, 2 } }, idat }, error.InvalidData); ++ try Helper.check(2, 8, &.{ .{ .kind = "tRNS", .body = &.{ 1, 0, 0, 0, 0, 0 } }, idat }, error.InvalidData); ++ try Helper.check(0, 16, &.{ .{ .kind = "tRNS", .body = &.{ 255, 255 } }, idat }, null); ++ for ([_]*const [4]u8{ "sBIT", "bKGD", "iCCP", "gAMA" }) |kind| { ++ try Helper.check(2, 8, &.{ .{ .kind = kind, .body = "" }, idat }, error.UnsupportedPngRetention); ++ } ++} ++ ++test "experimental PNG unsupported metadata falls back to strict decoding" { ++ const testing = std.testing; ++ const Decoder = struct { ++ var calls: usize = 0; ++ fn decode(_: Allocator, _: []const u8) sys.DecodeError!sys.Image { ++ calls += 1; ++ return error.InvalidData; ++ } ++ }; ++ const original_decoder = sys.decode_png; ++ defer sys.decode_png = original_decoder; ++ sys.decode_png = &Decoder.decode; ++ Decoder.calls = 0; ++ const png = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); ++ var loading: LoadingImage = .{ ++ .image = .{ .format = .png }, ++ .quiet = .failures, ++ .temporary_directory = null, ++ }; ++ defer loading.deinit(testing.allocator); ++ try loading.addData(testing.allocator, png[0..33]); ++ // CRC-valid ancillary chunk: inspection must delegate, not retain or ++ // return UnsupportedPngRetention to the caller. ++ var chunk = [_]u8{ 0, 0, 0, 1, 's', 'B', 'I', 'T', 8, 0, 0, 0, 0 }; ++ std.mem.writeInt(u32, chunk[9..13], std.hash.crc.Crc32IsoHdlc.hash(chunk[4..9]), .big); ++ try loading.addData(testing.allocator, &chunk); ++ try loading.addData(testing.allocator, png[33..]); ++ try testing.expectError(error.InvalidData, loading.completePreservingPng(testing.allocator)); ++ try testing.expectEqual(@as(usize, 1), Decoder.calls); ++} ++ ++test "native file completion and transactional lazy materialization" { ++ const testing = std.testing; ++ const Fake = struct { ++ reads: usize = 0, ++ releases: usize = 0, ++ fail: bool = false, ++ fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool { ++ const self: *@This() = @ptrCast(@alignCast(ctx.?)); ++ self.reads += 1; ++ if (self.fail) return false; ++ @memset(dest[0..len], 123); ++ return true; ++ } ++ fn release(ctx: ?*anyopaque) callconv(.c) void { ++ const self: *@This() = @ptrCast(@alignCast(ctx.?)); ++ self.releases += 1; ++ } ++ fn backing(self: *@This(), len: usize) FileBacking { ++ return .{ .context = self, .identity = 7, .len = len, .read = read, .release = release }; ++ } ++ }; ++ var fake: Fake = .{}; ++ var loading: LoadingImage = .{ ++ .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } }, ++ .quiet = .no, ++ .temporary_directory = null, ++ }; ++ defer loading.deinit(testing.allocator); ++ var img = try loading.complete(testing.allocator); ++ defer img.deinit(testing.allocator); ++ try testing.expectEqual(@as(usize, 0), fake.reads); ++ try testing.expectEqual(@as(usize, 4), img.data.len()); ++ try testing.expect(!img.data.isPending()); ++ try testing.expect(img.data.bytes() == null); ++ fake.fail = true; ++ try testing.expectError(error.InvalidData, img.materializeFile(testing.allocator)); ++ try testing.expect(img.data == .native_file); ++ try testing.expectEqual(@as(usize, 0), fake.releases); ++ fake.fail = false; ++ try img.materializeFile(testing.allocator); ++ try testing.expectEqual(@as(usize, 1), fake.releases); ++ try testing.expectEqualSlices(u8, &.{ 123, 123, 123, 123 }, img.data.bytes().?); ++ ++ var invalid: LoadingImage = .{ ++ .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(3) } }, ++ .quiet = .no, ++ .temporary_directory = null, ++ }; ++ try testing.expectError(error.InvalidData, invalid.complete(testing.allocator)); ++ invalid.deinit(testing.allocator); ++ try testing.expectEqual(@as(usize, 2), fake.releases); ++ // q=1 also validates length without reading pixels. ++ var quiet_one: LoadingImage = .{ ++ .image = .{ .width = 1, .height = 1, .format = .rgba, .data = .{ .native_file = fake.backing(4) } }, ++ .quiet = .ok, ++ .temporary_directory = null, ++ }; ++ const reads_before = fake.reads; ++ var quiet_img = try quiet_one.complete(testing.allocator); ++ quiet_one.deinit(testing.allocator); ++ try testing.expectEqual(reads_before, fake.reads); ++ quiet_img.deinit(testing.allocator); ++ try testing.expectEqual(@as(usize, 3), fake.releases); ++ var canceled: LoadingImage = .{ ++ .image = .{ .data = .{ .native_file = fake.backing(4) } }, ++ .quiet = .failures, ++ .temporary_directory = null, ++ }; ++ canceled.deinit(testing.allocator); ++ try testing.expectEqual(@as(usize, 4), fake.releases); ++} ++ ++test "native file eligibility is whole raw RGBA only" { ++ const testing = std.testing; ++ var t: command.Transmission = .{ .medium = .file, .format = .rgba, .width = 1, .height = 2 }; ++ try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t)); ++ t.size = 8; ++ try testing.expectEqual(@as(?usize, 8), LoadingImage.nativeFileExpectedLen(t)); ++ t.size = 7; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.size = 0; ++ t.more_chunks = true; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.more_chunks = false; ++ t.offset = 1; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.offset = 0; ++ t.format = .rgb; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.format = .png; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.format = .rgba; ++ t.medium = .temporary_file; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.medium = .shared_memory; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.medium = .file; ++ t.compression = .zlib_deflate; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.compression = .none; ++ t.width = 0; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.width = 10001; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++ t.width = 10000; ++ t.height = 10000; ++ try testing.expect(LoadingImage.nativeFileExpectedLen(t) == null); ++} ++ ++test "native file snapshot rejects missing callbacks before retaining" { ++ const testing = std.testing; ++ const Fake = struct { ++ reads: usize = 0, ++ releases: usize = 0, ++ missing_read: bool = true, ++ missing_release: bool = false, ++ ++ fn read(ctx: ?*anyopaque, dest: [*]u8, len: usize) callconv(.c) bool { ++ const self: *@This() = @ptrCast(@alignCast(ctx.?)); ++ self.reads += 1; ++ @memset(dest[0..len], 42); ++ return true; ++ } ++ ++ fn release(ctx: ?*anyopaque) callconv(.c) void { ++ const self: *@This() = @ptrCast(@alignCast(ctx.?)); ++ self.releases += 1; ++ } ++ ++ fn snapshot(ctx: ?*anyopaque, request: *const SnapshotFileRequest, out: *FileBacking) bool { ++ const self: *@This() = @ptrCast(@alignCast(ctx.?)); ++ out.* = .{ ++ .context = ctx, ++ .identity = 1, ++ .len = @intCast(request.expected_len), ++ .read = if (self.missing_read) null else &read, ++ .release = if (self.missing_release) null else &release, ++ }; ++ return true; ++ } ++ }; ++ var fake: Fake = .{}; ++ const hook: SnapshotFileHook = .{ .context = &fake, .callback = &Fake.snapshot }; ++ const request: SnapshotFileRequest = .{ .fd = -1, .expected_len = 4 }; ++ var loading: LoadingImage = .{ ++ .image = .{ .width = 1, .height = 1, .format = .rgba }, ++ .quiet = .no, ++ .temporary_directory = null, ++ }; ++ // Missing read: transferred context is immediately released, not stored. ++ try testing.expect(!try loading.trySnapshotFile(hook, request)); ++ try testing.expect(loading.image.data != .native_file); ++ try testing.expectEqual(@as(usize, 1), fake.releases); ++ ++ // Missing release (or both): reject without calling a null pointer. ++ fake.missing_read = false; ++ fake.missing_release = true; ++ try testing.expect(!try loading.trySnapshotFile(hook, request)); ++ try testing.expect(loading.image.data != .native_file); ++ fake.missing_read = true; ++ try testing.expect(!try loading.trySnapshotFile(hook, request)); ++ try testing.expect(loading.image.data != .native_file); ++ try testing.expectEqual(@as(usize, 0), fake.reads); ++ try testing.expectEqual(@as(usize, 1), fake.releases); ++ ++ // The ordinary reader can still supply bytes after rejection. ++ try loading.addData(testing.allocator, &.{ 1, 2, 3, 4 }); ++ var img = try loading.complete(testing.allocator); ++ loading.deinit(testing.allocator); ++ try testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4 }, img.data.bytes().?); ++ img.deinit(testing.allocator); ++ try testing.expectEqual(@as(usize, 1), fake.releases); ++ ++ // Valid callback ownership is unchanged: zero-read acceptance, one release. ++ fake.missing_read = false; ++ fake.missing_release = false; ++ var accepted: LoadingImage = .{ ++ .image = .{ .width = 1, .height = 1, .format = .rgba }, ++ .quiet = .no, ++ .temporary_directory = null, ++ }; ++ try testing.expect(try accepted.trySnapshotFile(hook, request)); ++ try testing.expect(accepted.image.data == .native_file); ++ try testing.expectEqual(@as(usize, 0), fake.reads); ++ accepted.deinit(testing.allocator); ++ try testing.expectEqual(@as(usize, 2), fake.releases); ++} +diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +index 897c8944..bd8cd424 100644 +--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig ++++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +@@ -154,7 +154,7 @@ pub const ImageStorage = struct { + if (img.generation != self.generation) return false; + + const expected_len = switch (img.data) { +- .complete => return false, ++ .complete, .encoded_png, .native_file => return false, + .pending => |len| len, + }; + if (data.len != expected_len) return false; +@@ -975,6 +975,15 @@ pub const ImageStorage = struct { + img.generation = self.generation; + } + ++ pub fn materializeBacking(self: *ImageStorage, io: std.Io, alloc: Allocator, img: *Image) !void { ++ if (img.data != .encoded_png and img.data != .native_file) return; ++ const before = img.data.len(); ++ try img.materializeFile(alloc); ++ try img.materializePng(alloc); ++ self.total_bytes -= before - img.data.len(); ++ self.markImageContentChanged(io, img); ++ } ++ + /// Convert a stored image's base data to RGBA in place, adjusting + /// byte accounting. All animation composition happens in RGBA; + /// this is called before the first composition into an image. From afd9e19893db5320b29d73f71f0fad58561f59b0 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 24 Sep 2026 21:00:57 +0200 Subject: [PATCH 60/99] perf: avoid redundant client ANSI encoding work (#4552) refs #4546 --- src/client/state.rs | 8 ++- src/protocol/render_ansi.rs | 139 ++++++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/client/state.rs b/src/client/state.rs index e6b8abf3..2cadfa62 100644 --- a/src/client/state.rs +++ b/src/client/state.rs @@ -348,9 +348,11 @@ impl ClientState { }; crate::render_prof::duration_since("client_surface_patch.encode", encode_started); let write_started = crate::render_prof::timer(); - let mut stdout = io::stdout(); - stdout.write_all(&encoded.bytes)?; - stdout.flush()?; + if !encoded.bytes.is_empty() { + let mut stdout = io::stdout(); + stdout.write_all(&encoded.bytes)?; + stdout.flush()?; + } crate::render_prof::duration_since("client_surface_patch.write", write_started); let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded); crate::render_prof::event(if committed { diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index fe3ab68f..98653601 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -147,6 +147,19 @@ impl BlitEncoder { if rows.iter().any(|row| !patch_row_fits(frame, row)) || patch_rows_overlap(rows) { return None; } + // Metadata revisions need no terminal output. Keep visible cursors on + // the normal path because their suppression policy can change. + if rows.is_empty() + && cursor == frame.cursor + && cursor.as_ref().is_none_or(|cursor| !cursor.visible) + { + return Some(EncodedBlit { + bytes: Vec::new(), + full: false, + next_last_visible_cursor: self.last_visible_cursor, + next_last_cursor_shape: self.last_cursor_shape, + }); + } let mut bytes = Vec::new(); let mut next_last_visible_cursor = self.last_visible_cursor; let mut next_last_cursor_shape = self.last_cursor_shape; @@ -553,16 +566,17 @@ fn patch_cell_mut(rows: &mut [PaneSurfacePatchRow], x: u16, y: u16) -> Option<&m } fn patch_rows_overlap(rows: &[PaneSurfacePatchRow]) -> bool { - rows.iter().enumerate().any(|(index, left)| { - let left_end = left.x.saturating_add(left.cells.len() as u16); - rows[index + 1..].iter().any(|right| { - if left.y != right.y { - return false; - } - let right_end = right.x.saturating_add(right.cells.len() as u16); - left.x < right_end && right.x < left_end - }) - }) + if rows.len() < 2 { + return false; + } + let mut spans = rows + .iter() + .map(|row| (row.y, row.x, row.x.saturating_add(row.cells.len() as u16))) + .collect::<Vec<_>>(); + spans.sort_unstable(); + spans + .windows(2) + .any(|pair| pair[0].0 == pair[1].0 && pair[1].1 < pair[0].2) } fn patch_row_fits(frame: &FrameData, row: &PaneSurfacePatchRow) -> bool { @@ -595,6 +609,7 @@ fn blit_patch_to( ) { let _ = writer.write_all(b"\x1b[?2026h\x1b[?25l\x1b]8;;\x1b\\"); let mut last_sgr = String::new(); + let mut last_style = None; let mut active_hyperlink = None; for row in rows { let mut invalidated = 0usize; @@ -612,6 +627,7 @@ fn blit_patch_to( cursor_position, cell, &mut last_sgr, + &mut last_style, &mut active_hyperlink, frame, ); @@ -838,6 +854,7 @@ fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorStat fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { let mut last_sgr = String::new(); + let mut last_style = None; let mut active_hyperlink = None; for row in 0..frame.height { let mut to_skip = 0usize; @@ -862,6 +879,7 @@ fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { cursor_position, cell, &mut last_sgr, + &mut last_style, &mut active_hyperlink, frame, ); @@ -937,6 +955,7 @@ fn write_cell( cursor_position: Option<(u16, u16)>, cell: &CellData, last_sgr: &mut String, + last_style: &mut Option<(u32, u32, u16)>, active_hyperlink: &mut Option<String>, frame: &FrameData, ) { @@ -948,10 +967,14 @@ fn write_cell( write_cursor_position(writer, position); } - let sgr = build_sgr(cell.fg, cell.bg, cell.modifier); - if sgr != *last_sgr { - let _ = writer.write_all(sgr.as_bytes()); - *last_sgr = sgr; + let style = (cell.fg, cell.bg, cell.modifier); + if *last_style != Some(style) { + let sgr = build_sgr(cell.fg, cell.bg, cell.modifier); + if sgr != *last_sgr { + let _ = writer.write_all(sgr.as_bytes()); + *last_sgr = sgr; + } + *last_style = Some(style); } write_hyperlink_if_changed(writer, active_hyperlink, cell_hyperlink_uri(frame, cell)); @@ -976,6 +999,7 @@ fn cells_visually_equal( fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) { let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes. + let mut last_style = None; let mut active_hyperlink = None; let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame); let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev); @@ -1008,6 +1032,7 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD cursor_position, cell, &mut last_sgr, + &mut last_style, &mut active_hyperlink, frame, ); @@ -1139,6 +1164,26 @@ mod tests { assert_eq!(build_sgr(0x00_00_00_00, 0x00_00_00_00, 0), "\x1b[0;39;49m"); } + #[test] + fn repeated_and_equivalent_styles_keep_text_and_link_changes() { + let mut cells = vec![ + make_cell("a", 0, 0, 0), + make_cell("b", 0, 0, 0), + make_cell("c", 0xff_00_00_00, 0, 0), // Unknown color also encodes as reset. + make_cell("d", 2, 0, 0), + make_cell("e", 0, 0, 0), + ]; + cells[1].hyperlink = Some(0); + let mut frame = make_frame(5, 1, cells); + frame.hyperlinks.push("https://example.com".into()); + let mut output = Vec::new(); + write_all_cells(&mut output, &frame); + assert_eq!( + String::from_utf8(output).unwrap(), + "\x1b[1;1H\x1b[0;39;49ma\x1b]8;;https://example.com\x1b\\b\x1b]8;;\x1b\\c\x1b[0;31;49md\x1b[0;39;49me\x1b[0m" + ); + } + #[test] fn build_sgr_preserves_curly_underline_style() { let modifier = crate::protocol::modifier_to_u16( @@ -1855,6 +1900,72 @@ mod tests { ]; assert!(encoder.encode_patch(&rows, None, false).is_none()); + let mut reversed = rows.clone(); + reversed.reverse(); + assert!(encoder.encode_patch(&reversed, None, false).is_none()); + + // Input order need not match screen order; touching runs are disjoint. + let disjoint = vec![ + PaneSurfacePatchRow { + x: 2, + y: 0, + cells: vec![make_cell("Z", 0, 0, 0)], + }, + rows[0].clone(), + ]; + assert!(encoder.encode_patch(&disjoint, None, false).is_some()); + } + + #[test] + fn metadata_only_patches_do_not_write_but_cursor_changes_do() { + let mut frame = make_frame(3, 1, vec![make_cell("a", 0, 0, 0); 3]); + let mut encoder = BlitEncoder::new(); + for cursor in [ + None, + Some(CursorState { + x: 0, + y: 0, + visible: false, + shape: 2, + }), + ] { + frame.cursor = cursor.clone(); + let initial = encoder.encode(&frame, false); + encoder.commit(frame.clone(), initial); + let encoded = encoder.encode_patch(&[], cursor.clone(), false).unwrap(); + assert!(encoded.bytes.is_empty()); + assert!(encoder.commit_patch(&[], cursor, encoded)); + assert!(encoder.is_current(&frame)); + } + for cursor in [ + CursorState { + x: 2, + y: 0, + visible: false, + shape: 2, + }, + CursorState { + x: 2, + y: 0, + visible: true, + shape: 2, + }, + ] { + let encoded = encoder + .encode_patch(&[], Some(cursor.clone()), false) + .unwrap(); + assert!(String::from_utf8_lossy(&encoded.bytes).contains("\x1b[1;3H")); + assert!(encoder.commit_patch(&[], Some(cursor), encoded)); + } + // Switching to a client-drawn cursor must still hide the visible host cursor. + let encoded = encoder + .encode_patch( + &[], + encoder.last_frame.as_ref().unwrap().cursor.clone(), + true, + ) + .unwrap(); + assert!(String::from_utf8_lossy(&encoded.bytes).contains("\x1b[?25l")); } #[test] From 8d64c344db831a41fef3b424bb97bdd180d34164 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 24 Sep 2026 21:22:51 +0200 Subject: [PATCH 61/99] perf: avoid repeated scrollback page lookups (#4553) refs #4506 --- src/pane/terminal.rs | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 66c23c26..53d8e32d 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2946,12 +2946,12 @@ fn ghostty_recent_text_for_terminal( terminal: &crate::ghostty::Terminal, lines: usize, ) -> Result<String, crate::ghostty::Error> { - let Some((start, end, cols)) = ghostty_recent_read_range(terminal, lines)? else { + let Some((start, end, _)) = ghostty_recent_read_range(terminal, lines)? else { return Ok(String::new()); }; let mut rows = Vec::with_capacity(end.saturating_sub(start).saturating_add(1)); for y in start..=end { - rows.push(ghostty_screen_row(terminal, cols, y as u32)?); + rows.push(ghostty_screen_row(terminal, y as u32)?); } trim_trailing_blank_rows(&mut rows); Ok(recent_text_from_rows(&rows, lines)) @@ -3013,10 +3013,7 @@ fn ghostty_recent_read_range( .min(total_rows.saturating_sub(1)); let mut last_content_row = None; for row in (viewport_start..total_rows).rev() { - if !ghostty_screen_row(terminal, cols, row as u32)? - .trim() - .is_empty() - { + if !ghostty_screen_row(terminal, row as u32)?.trim().is_empty() { last_content_row = Some(row); break; } @@ -3056,12 +3053,15 @@ fn ghostty_extract_selection( fn ghostty_screen_row( terminal: &crate::ghostty::Terminal, - cols: u16, y: u32, ) -> Result<String, crate::ghostty::Error> { let mut line = String::new(); - for x in 0..cols { - let (wide, graphemes) = terminal.screen_cell(x, y)?; + // Resolve the scrollback page once per row rather than once per column. + for crate::ghostty::ScreenTextCell { wide, graphemes } in terminal + .screen_text_rows_range(y as usize, y as usize + 1)? + .into_iter() + .flat_map(|row| row.cells) + { if wide == crate::ghostty::CellWide::SpacerTail { continue; } @@ -5807,6 +5807,20 @@ mod tests { assert_eq!(pane.detection_text(), "日本語テスト ABC 123\n"); } + #[test] + fn recent_rows_preserve_combining_text_and_hide_image_placeholders() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(40, 3, 1024 * 1024).unwrap(); + terminal.write("old\r\n".repeat(100).as_bytes()); + terminal.write("界 e\u{301} \u{10eeee} tail ".as_bytes()); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + assert_eq!(pane.recent_text(1), "界 e\u{301} tail\n"); + let detection = pane.detection_text(); + assert_eq!(detection, "old\nold\n界 e\u{301} tail\n"); + pane.set_scroll_offset_from_bottom(100); + assert_eq!(pane.detection_text(), detection); + } + #[test] fn visible_ansi_preserves_cell_style_sequences() { let (tx, _rx) = mpsc::channel(4); From a28bed2b294ea85ba3416b337f6b16a13556a1e6 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Thu, 24 Sep 2026 21:33:45 +0200 Subject: [PATCH 62/99] docs: explain tmux auto-attach guard in herdr panes (#4592) --- docs/next/website/src/content/docs/agents.mdx | 8 ++++++++ docs/next/website/src/content/docs/ja/agents.mdx | 8 ++++++++ docs/next/website/src/content/docs/zh-cn/agents.mdx | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index aaf6b5fb..78274850 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -91,6 +91,14 @@ Live explain is evaluated by the running server, so it reflects the active manif Herdr can run inside tmux as the outer terminal environment. Agent detection does not inspect tmux sessions launched inside a Herdr pane. If a shell framework auto-enters tmux inside Herdr, Herdr sees `tmux` as the pane process instead of the agent behind it. +If your shell automatically attaches to tmux whenever `TMUX` is unset, update its startup rule to exclude Herdr panes. Reattaching the outer tmux session from inside a Herdr pane can recursively shrink the terminal and make it flicker. Add the `HERDR_ENV` check to your existing condition before it runs tmux: + +```zsh +if [[ -z ${TMUX:-} && ${HERDR_ENV:-} != 1 ]]; then + tmux new-session -A -s my-session +fi +``` + ## State rollups The sidebar rolls state upward. diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 3a365221..528ab0e4 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -89,6 +89,14 @@ herdr agent explain --file screen.txt --agent codex --json Herdr は外側のターミナル環境として tmux の中で動かせます。エージェント検出は、Herdr のペイン内で起動された tmux セッションの中までは調べません。シェルフレームワークが Herdr 内で自動的に tmux に入る場合、Herdr はペインのプロセスとして背後のエージェントではなく `tmux` を見ることになります。 +`TMUX` が未設定のときにシェルが自動的に tmux に接続する場合、シェルの起動条件を更新して Herdr のペインを除外してください。Herdr のペイン内から外側の tmux セッションに再接続すると、ターミナルのサイズが再帰的に縮小し、画面がちらつくことがあります。既存の条件に、tmux を実行する前に `HERDR_ENV` の確認を追加します: + +```zsh +if [[ -z ${TMUX:-} && ${HERDR_ENV:-} != 1 ]]; then + tmux new-session -A -s my-session +fi +``` + ## 状態のロールアップ サイドバーは状態を上位へ集約します。 diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx index bc749add..80d572c6 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -89,6 +89,14 @@ herdr agent explain --file screen.txt --agent codex --json Herdr 可以在 tmux 作为外层终端环境时运行。智能体检测不会检查在 Herdr 窗格内启动的 tmux 会话。如果某个 shell 框架在 Herdr 内自动进入 tmux,Herdr 看到的窗格进程就是 `tmux`,而不是它背后的智能体。 +如果你的 shell 在 `TMUX` 未设置时自动连接 tmux,请更新 shell 启动条件以排除 Herdr 窗格。从 Herdr 窗格内重新连接外层 tmux 会话可能导致终端尺寸递归缩小、画面闪烁。在现有条件中加入 `HERDR_ENV` 检查,并确保它在运行 tmux 之前生效: + +```zsh +if [[ -z ${TMUX:-} && ${HERDR_ENV:-} != 1 ]]; then + tmux new-session -A -s my-session +fi +``` + ## 状态汇总 侧边栏会把状态向上汇总。 From 8d95e9bd45338c43091e04ef0c976086bb09163a Mon Sep 17 00:00:00 2001 From: Marco Mayer <marcomayer@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:12:04 +0200 Subject: [PATCH 63/99] fix: keep copy mode active during projection updates (#4281) * fix: keep copy mode active during projection updates refs #3812 * fix: preserve linewise selections during projection gaps refs #3812 --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com> --- src/client/shell/copy_mode.rs | 26 ++++------ src/client/shell/tests/copy.rs | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs index 07dc8d18..be5b63e3 100644 --- a/src/client/shell/copy_mode.rs +++ b/src/client/shell/copy_mode.rs @@ -504,13 +504,10 @@ impl ClientShellState { } fn move_copy_cursor(&mut self, row_delta: i16, col_delta: i16, outcome: &mut ClientShellInput) { - let Some(hit) = self.copy_hit() else { - self.exit_copy_mode(false, outcome); - return; - }; let Some(copy_mode) = self.copy_mode.as_mut() else { return; }; + let (width, height) = copy_mode.geometry; if col_delta < 0 { copy_mode.cursor.col = copy_mode .cursor @@ -521,11 +518,11 @@ impl ClientShellState { .cursor .col .saturating_add(col_delta as u16) - .min(hit.inner_rect.width.saturating_sub(1)); + .min(width.saturating_sub(1)); } let total_rows = copy_mode .max_offset_from_bottom - .saturating_add(hit.inner_rect.height as usize) + .saturating_add(height as usize) .max(1); if row_delta < 0 { copy_mode.cursor.row = copy_mode @@ -610,16 +607,13 @@ impl ClientShellState { } fn reveal_copy_cursor(&mut self, outcome: &mut ClientShellInput, reserve_mode_bar_row: bool) { - let Some(hit) = self.copy_hit() else { - return; - }; let request = self.copy_mode.as_mut().and_then(|copy_mode| { let current_top = copy_mode .max_offset_from_bottom .saturating_sub(copy_mode.offset_from_bottom) as u32; - let max_cursor_row = hit - .inner_rect - .height + let max_cursor_row = copy_mode + .geometry + .1 .saturating_sub(if reserve_mode_bar_row { 2 } else { 1 }); let bottom = current_top.saturating_add(u32::from(max_cursor_row)); let desired_top = if copy_mode.cursor.row < current_top { @@ -647,12 +641,11 @@ impl ClientShellState { } fn begin_copy_selection(&mut self, linewise: bool) { - let end_col = self - .copy_hit() - .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)); + let width = self.copy_hit().map(|hit| hit.inner_rect.width); let Some(copy_mode) = self.copy_mode.as_mut() else { return; }; + let end_col = width.unwrap_or(copy_mode.geometry.0).saturating_sub(1); if linewise { copy_mode.selection = Some(ClientCopySelection::Linewise { anchor_row: copy_mode.cursor.row, @@ -695,7 +688,8 @@ impl ClientShellState { anchor_row, copy_mode.cursor.row, self.copy_hit() - .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)), + .map_or(copy_mode.geometry.0, |hit| hit.inner_rect.width) + .saturating_sub(1), ) } }); diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 67e36f23..c0d528e1 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -2216,3 +2216,95 @@ fn word_selection_result_survives_focus_snapshot_lag() { .as_ref() .is_some_and(crate::selection::Selection::is_visible)); } + +#[test] +fn copy_mode_repeat_during_projection_gap_stays_active() { + for selection_before_gap in [None, Some(true), Some(false)] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + let mut enter = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CopyMode), + &mut enter, + ); + if selection_before_gap == Some(true) { + state.handle_input_bytes(b"V"); + } + state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('k'), + KeyModifiers::empty(), + ))]); + + let end_col = state.copy_mode.as_ref().expect("copy mode").geometry.0 - 1; + let mut next = snapshot(); + next.revision += 1; + state.set_snapshot(Box::new(next)); + assert!(state.hits.panes.is_empty()); + assert_eq!(state.mode, ClientShellMode::Copy); + if selection_before_gap == Some(false) { + state.handle_input_bytes(b"V"); + assert_eq!( + state + .selection + .as_ref() + .expect("linewise selection") + .ordered_cells(), + ((20, 0), (20, end_col)) + ); + } + + let kind = if selection_before_gap == Some(false) { + crossterm::event::KeyEventKind::Press + } else { + crossterm::event::KeyEventKind::Repeat + }; + let moved = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()) + .with_kind(kind), + )]); + assert!(moved.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::PaneScroll(params) + if params.pane_id == "pane_1" && params.offset_from_bottom == 1) + ))); + if selection_before_gap.is_some() { + assert_eq!( + state + .selection + .as_ref() + .expect("linewise selection") + .ordered_cells(), + ( + (19, 0), + ( + if selection_before_gap == Some(true) { + 21 + } else { + 20 + }, + end_col + ) + ) + ); + } + + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.is_some()); + assert_eq!( + state + .copy_mode + .as_ref() + .map(|copy_mode| copy_mode.cursor.row), + Some(19) + ); + } +} From 21d0ce60267ad947c081d3d3fba401c859f06dd2 Mon Sep 17 00:00:00 2001 From: Ainesh Chatterjee <ainesh@factory.ai> Date: Fri, 25 Sep 2026 10:04:54 -0700 Subject: [PATCH 64/99] fix: preserve shift on ctrl+shift+letter for legacy panes (#4597) Panes whose app has not enabled the kitty keyboard protocol received ctrl+shift+letter as the same C0 byte as ctrl+letter. Encode the chord as CSI-u instead, matching Ghostty's legacy encoder. refs #4581 Co-authored-by: ain3sh <ainesh.chatterjee@gmail.com> --- src/input/encode.rs | 61 +++++++++++++++++++++++++++++++++++++++++--- src/pane/terminal.rs | 2 +- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/input/encode.rs b/src/input/encode.rs index 58543232..91b28fc8 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -23,12 +23,12 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec< return Vec::new(); } - // Super has no legacy character encoding. Preserve the chord with CSI-u - // instead of leaking the unmodified character into the pane. + // Super has no legacy character encoding, and Ctrl+Shift+letter would + // collapse into the same C0 byte as Ctrl+letter. Preserve both chords with + // CSI-u, matching Ghostty's legacy encoder. if matches!(protocol, KeyboardProtocol::Legacy) && key.kind != crossterm::event::KeyEventKind::Release - && matches!(key.code, KeyCode::Char(_)) - && key.modifiers.contains(KeyModifiers::SUPER) + && legacy_chord_needs_csi_u(&key) { if let Some(bytes) = try_encode_csi_u(&key, 0) { return bytes; @@ -81,6 +81,17 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec< encode_legacy(key) } +fn legacy_chord_needs_csi_u(key: &TerminalKey) -> bool { + let KeyCode::Char(ch) = key.code else { + return false; + }; + key.modifiers.contains(KeyModifiers::SUPER) + || (ch.is_ascii_alphabetic() + && key + .modifiers + .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)) +} + #[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers pub fn encode_cursor_key(code: KeyCode, application_cursor: bool) -> Vec<u8> { match (code, application_cursor) { @@ -625,6 +636,48 @@ mod tests { assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]); } + #[test] + fn legacy_ctrl_shift_letter_preserves_shift_with_csi_u() { + for ch in ['c', 'C'] { + let key = KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!( + encode_key(key, KeyboardProtocol::Legacy), + b"\x1b[99;6u", + "ch={ch}" + ); + } + } + + #[test] + fn legacy_ctrl_alt_shift_letter_uses_csi_u() { + let key = KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[99;8u"); + } + + #[test] + fn legacy_ctrl_shift_letter_from_kitty_host_input_keeps_shift() { + let key = parse_terminal_key_sequence("\x1b[99:67;6:1u").expect("Ctrl+Shift+C"); + assert_eq!( + encode_terminal_key(key, KeyboardProtocol::Legacy), + b"\x1b[99;6u" + ); + } + + #[test] + fn legacy_ctrl_shift_punctuation_keeps_c0_byte() { + let key = KeyEvent::new( + KeyCode::Char('_'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![31]); + } + #[test] fn legacy_ctrl_slash_aliases_ctrl_underscore() { let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 53d8e32d..d0b9d3ca 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -4959,7 +4959,7 @@ mod tests { let encoded = pane.encode_terminal_key( crate::input::TerminalKey::new( crossterm::event::KeyCode::Char('a'), - crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, + crossterm::event::KeyModifiers::CONTROL, ), crate::input::KeyboardProtocol::Legacy, ); From 7184be49fe0dbaf62a53ebd56ac5a830c9fb6abf Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Sat, 26 Sep 2026 02:20:08 +0300 Subject: [PATCH 65/99] fix: retain mouse input grace for split csi prefixes (#4636) refs #4630 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> --- src/client/input.rs | 52 ++++++++++++++++++++++++++++++++++++++++++--- src/raw_input.rs | 5 +++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index 45656f84..23067576 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -371,8 +371,12 @@ fn idle_flush_timeout_ms( framer: &crate::raw_input::RawInputByteFramer, host_mouse_capture_active: bool, ) -> i32 { + // A mouse report split after ESC[ is still ambiguous with legacy Alt+[. + // Give it the mouse-active grace period without changing timeout decoding. if host_mouse_capture_active - && (framer.has_pending_lone_escape() || framer.has_pending_incomplete_mouse_sequence()) + && (framer.has_pending_lone_escape() + || framer.has_pending_csi_introducer() + || framer.has_pending_incomplete_mouse_sequence()) { crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS } else { @@ -769,10 +773,52 @@ mod tests { assert_eq!(framer.flush_timeout().len(), 1); } + #[test] + fn captured_mouse_report_split_after_csi_survives_idle_gap() { + let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + framer.enable_host_appearance_query_on_focus(); + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b[").is_empty()); + + // #4630 input.bin offsets 10465/10467: ESC[ and its continuation + // arrived 32.937 ms apart. Apply the Unix reader's two idle waits + // without sleeping, using its production timeout selector. + let gap = std::time::Duration::from_micros(32_937); + let first_wait = + std::time::Duration::from_millis(idle_flush_timeout_ms(&framer, true) as u64); + let mut chunks = Vec::new(); + if gap >= first_wait { + chunks.extend(framer.flush_timeout()); + if chunks.is_empty() + && gap + >= first_wait + + std::time::Duration::from_millis( + crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS as u64, + ) + { + chunks.extend(framer.flush_timeout()); + } + } + chunks.extend(framer.push(b"<35;64;37M\x1b[<35;65;36M\x1b[<35;64;36M")); + assert_eq!( + chunks, + vec![ + b"\x1b[<35;64;37M".to_vec(), + b"\x1b[<35;65;36M".to_vec(), + b"\x1b[<35;64;36M".to_vec(), + ], + "captured mouse reports must remain whole, not become key fragments" + ); + assert!(!framer.has_pending_input()); + assert_eq!(framer.push(b"x"), vec![b"x".to_vec()]); + } + #[test] fn mouse_active_escape_sequences_get_longer_reassembly_window() { let mut escape = crate::raw_input::RawInputByteFramer::default(); assert!(escape.push(b"\x1b").is_empty()); + let mut csi = crate::raw_input::RawInputByteFramer::default(); + assert!(csi.push(b"\x1b[").is_empty()); let mut sgr_mouse = crate::raw_input::RawInputByteFramer::default(); assert!(sgr_mouse.push(b"\x1b[<3").is_empty()); let mut default_mouse = crate::raw_input::RawInputByteFramer::default(); @@ -780,13 +826,13 @@ mod tests { let mut unrelated = crate::raw_input::RawInputByteFramer::default(); assert!(unrelated.push(b"\x1b[49:33;2:").is_empty()); - for framer in [&escape, &sgr_mouse, &default_mouse, &unrelated] { + for framer in [&escape, &csi, &sgr_mouse, &default_mouse, &unrelated] { assert_eq!( idle_flush_timeout_ms(framer, false), crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS ); } - for framer in [&escape, &sgr_mouse, &default_mouse] { + for framer in [&escape, &csi, &sgr_mouse, &default_mouse] { assert_eq!( idle_flush_timeout_ms(framer, true), crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS diff --git a/src/raw_input.rs b/src/raw_input.rs index cfbcba62..b2c3beda 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -229,6 +229,11 @@ impl RawInputByteFramer { self.buffer.as_slice() == [ESC] } + #[cfg(unix)] + pub(crate) fn has_pending_csi_introducer(&self) -> bool { + self.buffer.as_slice() == b"\x1b[" + } + #[cfg(unix)] pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { starts_with_incomplete_sgr_mouse_sequence(&self.buffer) From 81ddfc65b4661c52569f8b8d1ee41d5a6d68f85e Mon Sep 17 00:00:00 2001 From: akbash <akbash@herdr.dev> Date: Sat, 26 Sep 2026 03:13:20 +0300 Subject: [PATCH 66/99] fix: accept plugin install options before the repository (#4453) refs #4446 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com> --- src/cli/plugin.rs | 123 ++++++++++++++++++++++++++++++++++++------- tests/cli/plugins.rs | 37 ++++++++++++- 2 files changed, 139 insertions(+), 21 deletions(-) diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index a2c1e6c3..46caf509 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -15,6 +15,8 @@ use crate::api::schema::{ use crate::popup_size::PopupSize; const PLUGIN_BUILD_OUTPUT_MAX_BYTES: usize = 64 * 1024; +const PLUGIN_INSTALL_USAGE: &str = + "usage: herdr plugin install [--ref REF] [--yes|-y] <owner>/<repo>[/subdir...]"; pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { @@ -151,39 +153,58 @@ fn plugin_unlink(args: &[String]) -> std::io::Result<i32> { })) } -fn plugin_install(args: &[String]) -> std::io::Result<i32> { - let Some(source_arg) = args.first() else { - eprintln!("usage: herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]"); - return Ok(2); - }; - let source = match GithubPluginSource::parse(source_arg) { - Ok(source) => source, - Err(err) => { - eprintln!("{err}"); - return Ok(2); - } - }; +#[derive(Debug)] +struct PluginInstallArgs { + source: GithubPluginSource, + requested_ref: Option<String>, + yes: bool, +} + +fn parse_plugin_install_args(args: &[String]) -> Result<PluginInstallArgs, String> { + let mut source_arg = None; let mut requested_ref = None; let mut yes = false; - let mut index = 1; + let mut index = 0; while index < args.len() { match args[index].as_str() { "--ref" => { - let Some(value) = required_value(args, &mut index, "--ref") else { - return Ok(2); - }; - requested_ref = Some(value); + let value = args.get(index + 1).ok_or("missing value for --ref")?; + requested_ref = Some(value.clone()); + index += 2; } "--yes" | "-y" => { yes = true; index += 1; } + other if other.starts_with('-') || source_arg.is_some() => { + return Err(format!("unknown option: {other}")); + } other => { - eprintln!("unknown option: {other}"); - return Ok(2); + source_arg = Some(other); + index += 1; } } } + let source = GithubPluginSource::parse(source_arg.ok_or(PLUGIN_INSTALL_USAGE)?)?; + Ok(PluginInstallArgs { + source, + requested_ref, + yes, + }) +} + +fn plugin_install(args: &[String]) -> std::io::Result<i32> { + let PluginInstallArgs { + source, + requested_ref, + yes, + } = match parse_plugin_install_args(args) { + Ok(args) => args, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; if !yes && !io::stdin().is_terminal() { eprintln!("remote plugin install requires --yes when stdin is not interactive"); @@ -737,7 +758,7 @@ impl GithubPluginSource { } let parts = value.split('/').collect::<Vec<_>>(); if parts.len() < 2 { - return Err("usage: herdr plugin install <owner>/<repo>[/subdir...]".into()); + return Err(PLUGIN_INSTALL_USAGE.into()); } let owner = parts[0]; let repo = parts[1]; @@ -1737,6 +1758,68 @@ mod tests { } } + #[test] + fn plugin_install_args_accept_options_around_source() { + for (args, expected_ref, expected_yes) in [ + (vec!["owner/repo"], None, false), + (vec!["--yes", "owner/repo"], None, true), + (vec!["-y", "owner/repo"], None, true), + ( + vec!["owner/repo", "--ref", "main", "-y"], + Some("main"), + true, + ), + ( + vec!["--ref", "main", "owner/repo", "--yes"], + Some("main"), + true, + ), + ( + vec![ + "--yes", + "--ref", + "old", + "owner/repo", + "--ref", + "main", + "--yes", + ], + Some("main"), + true, + ), + // Preserve ref-value consumption: a value named --yes is not consent. + (vec!["owner/repo", "--ref", "--yes"], Some("--yes"), false), + ] { + let args = args.into_iter().map(String::from).collect::<Vec<_>>(); + let parsed = parse_plugin_install_args(&args).unwrap(); + assert_eq!(parsed.source.display(), "owner/repo", "{args:?}"); + assert_eq!(parsed.requested_ref.as_deref(), expected_ref, "{args:?}"); + assert_eq!(parsed.yes, expected_yes, "{args:?}"); + } + } + + #[test] + fn plugin_install_args_reject_invalid_syntax() { + for (args, expected_error) in [ + (vec![], PLUGIN_INSTALL_USAGE), + (vec!["--yes"], PLUGIN_INSTALL_USAGE), + (vec!["owner/repo", "--ref"], "missing value for --ref"), + (vec!["--unknown", "owner/repo"], "unknown option: --unknown"), + ( + vec!["owner/repo", "extra/repo"], + "unknown option: extra/repo", + ), + (vec!["owner"], PLUGIN_INSTALL_USAGE), + ] { + let args = args.into_iter().map(String::from).collect::<Vec<_>>(); + assert_eq!( + parse_plugin_install_args(&args).unwrap_err(), + expected_error, + "{args:?}" + ); + } + } + #[test] fn github_plugin_source_parses_root_repo() { let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap(); diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs index 6726c243..23f1b352 100644 --- a/tests/cli/plugins.rs +++ b/tests/cli/plugins.rs @@ -387,6 +387,36 @@ command = ["sh", "-c", "sleep 5"] cleanup_spawned_herdr(herdr, base); } +#[test] +fn plugin_install_usage_errors_include_options_without_installing() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let state_home = base.join("state"); + fs::create_dir_all(&runtime_dir).unwrap(); + + for args in [ + vec!["plugin", "install"], + vec!["plugin", "install", "owner"], + ] { + let output = run_named_cli_with_env( + &config_home, + &runtime_dir, + &args, + &[("XDG_STATE_HOME", &state_home)], + ); + assert_eq!(output.status.code(), Some(2), "{args:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("usage: herdr plugin install"), "{stderr}"); + assert!(stderr.contains("[--ref REF] [--yes|-y]"), "{stderr}"); + assert!(!config_home.join("herdr-dev/plugins").exists()); + assert!(!config_home.join("herdr-dev/plugins.json").exists()); + assert!(!state_home.exists()); + } + + cleanup_test_base(&base); +} + #[test] fn plugin_install_list_uninstall_offline_cli_smoke_test() { let base = unique_test_dir(); @@ -433,6 +463,7 @@ command = ["sh", "-c", "echo bootstrap"] ) .unwrap(); + run_git(&source_repo, &["tag", "v0.43.0"]); let install = run_named_cli_with_env( &config_home, &runtime_dir, @@ -441,11 +472,14 @@ command = ["sh", "-c", "echo bootstrap"] "plugins", "plugin", "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", "--yes", + "--ref", + "v0.43.0", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", ], &[ ("GIT_CONFIG_GLOBAL", &git_config), + ("XDG_STATE_HOME", &base.join("state")), ("HERDR_SESSION", Path::new("leaked-session")), ], ); @@ -467,6 +501,7 @@ command = ["sh", "-c", "echo bootstrap"] assert_eq!(plugin["source"]["owner"], "ogulcancelik"); assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples"); assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap"); + assert_eq!(plugin["source"]["requested_ref"], "v0.43.0"); assert!(plugin["source"]["resolved_commit"].as_str().is_some()); let managed_path = PathBuf::from(plugin["source"]["managed_path"].as_str().unwrap()); assert!(managed_path.exists(), "managed checkout should exist"); From 804a4aaf3ec21e681a09d3021d96b33206280b16 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sat, 26 Sep 2026 17:33:00 +0300 Subject: [PATCH 67/99] fix: crop kitty images around overlays instead of hiding them (#4652) --- src/kitty_graphics/surface.rs | 625 +++++++++++++++++++++++++++++----- 1 file changed, 545 insertions(+), 80 deletions(-) diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index 9e03d642..5c13b6ef 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -66,12 +66,47 @@ impl Occlusion { self.popup_start = self.regions.len(); } - fn covers( + #[cfg(test)] + fn covers(&self, placement: &SurfaceGraphicsPlacement, origin: (u16, u16)) -> bool { + self.visible_pieces(placement, origin, 1) != Some(vec![GridPiece::whole(placement)]) + } + + /// Visible pieces for each placement in a frame. When cropping would exceed + /// the frame's piece budget, touched images are hidden whole instead. + fn frame_pieces( + &self, + placements: &[(&SurfaceGraphicsPlacement, (u16, u16))], + ) -> Vec<Vec<GridPiece>> { + let mut extra_budget = MAX_EXTRA_CROP_PIECES; + let mut pieces = Vec::with_capacity(placements.len()); + for &(placement, origin) in placements { + let Some(found) = self.visible_pieces(placement, origin, extra_budget + 1) else { + return placements + .iter() + .map(|&(placement, origin)| { + let whole = vec![GridPiece::whole(placement)]; + if self.visible_pieces(placement, origin, 1).as_ref() == Some(&whole) { + whole + } else { + Vec::new() + } + }) + .collect(); + }; + extra_budget -= found.len().saturating_sub(1); + pieces.push(found); + } + pieces + } + + /// Splits a placement's cell grid into the rectangles no overlay touches, + /// or returns `None` when that would take more than `limit` pieces. + fn visible_pieces( &self, placement: &SurfaceGraphicsPlacement, origin: (u16, u16), - cell: HostCellSize, - ) -> bool { + limit: usize, + ) -> Option<Vec<GridPiece>> { let regions = if matches!( placement.asset.source, SurfaceGraphicsSource::Terminal { @@ -83,23 +118,84 @@ impl Occlusion { } else { &self.regions }; - if regions.is_empty() { - return false; + let mut pieces = vec![GridPiece::whole(placement)]; + // Pixel offsets shrink an image inside its cells, so cell overlap is exact. + let x = u32::from(origin.0.saturating_add(placement.x)); + let y = u32::from(origin.1.saturating_add(placement.y)); + let covered_span = |start: u16, end: u16, image_start: u32, len: u32| { + let first = u32::from(start).saturating_sub(image_start); + let last = u32::from(end).saturating_sub(image_start).min(len); + (first < last).then_some((first, last)) + }; + for rect in regions { + let Some((col_start, col_end)) = covered_span(rect.x, rect.right(), x, placement.cols) + else { + continue; + }; + let Some((row_start, row_end)) = covered_span(rect.y, rect.bottom(), y, placement.rows) + else { + continue; + }; + pieces = pieces + .into_iter() + .flat_map(|piece| piece.subtract(col_start, col_end, row_start, row_end)) + .collect(); + if pieces.is_empty() { + return Some(pieces); + } + if pieces.len() > limit { + return None; + } + } + Some(pieces) + } +} + +/// Extra placements one frame may add by cropping around overlays. Beyond it, +/// the frame hides touched images whole so the quadratic host encoder stays cheap. +const MAX_EXTRA_CROP_PIECES: usize = 32; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GridPiece { + col: u32, + row: u32, + cols: u32, + rows: u32, +} + +impl GridPiece { + fn whole(placement: &SurfaceGraphicsPlacement) -> Self { + Self { + col: 0, + row: 0, + cols: placement.cols, + rows: placement.rows, + } + } + + fn subtract(self, col_start: u32, col_end: u32, row_start: u32, row_end: u32) -> Vec<Self> { + let right = self.col + self.cols; + let bottom = self.row + self.rows; + if col_end <= self.col || col_start >= right || row_end <= self.row || row_start >= bottom { + return vec![self]; } - let width = u64::from(cell.width_px); - let height = u64::from(cell.height_px); - let x = - u64::from(origin.0.saturating_add(placement.x)) * width + u64::from(placement.x_offset); - let y = u64::from(origin.1.saturating_add(placement.y)) * height - + u64::from(placement.y_offset); - let right = x + u64::from(placement.cols) * width; - let bottom = y + u64::from(placement.rows) * height; - regions.iter().any(|rect| { - x < u64::from(rect.right()) * width - && right > u64::from(rect.x) * width - && y < u64::from(rect.bottom()) * height - && bottom > u64::from(rect.y) * height + let middle_top = row_start.max(self.row); + let middle_bottom = row_end.min(bottom); + [ + (self.col, self.row, right, middle_top), + (self.col, middle_bottom, right, bottom), + (self.col, middle_top, col_start.max(self.col), middle_bottom), + (col_end.min(right), middle_top, right, middle_bottom), + ] + .into_iter() + .filter(|(left, top, right, bottom)| left < right && top < bottom) + .map(|(left, top, right, bottom)| Self { + col: left, + row: top, + cols: right - left, + rows: bottom - top, }) + .collect() } } @@ -399,23 +495,32 @@ impl ClientState { return GraphicsOutput::from_bytes(bytes); } - let placements = self + let visible = self .display_scene .placements .iter() .filter_map(|placement| { - client_host_placement( - &self.scope, - placement, - visibility, - main_origin, - popup_origin, - cell_size, - occlusion, - ) - .map(|mut host| { - host.host_image_id = Some(self.image_id(&placement.asset)); - host.raw_data = self.assets.get(&placement.asset).map(Arc::clone); + client_placement_origin(placement, visibility, main_origin, popup_origin) + .map(|origin| (placement, origin)) + }) + .collect::<Vec<_>>(); + let pieces = occlusion.frame_pieces(&visible); + let this = &*self; + let placements = visible + .iter() + .zip(pieces) + .flat_map(|(&(placement, origin), pieces)| { + pieces.into_iter().enumerate().map(move |(index, piece)| { + let mut host = client_host_piece( + &this.scope, + placement, + origin, + cell_size, + piece, + index as u32, + ); + host.host_image_id = Some(this.image_id(&placement.asset)); + host.raw_data = this.assets.get(&placement.asset).map(Arc::clone); host }) }) @@ -729,15 +834,12 @@ fn asset_key( } } -fn client_host_placement( - scope: &str, +fn client_placement_origin( placement: &SurfaceGraphicsPlacement, visibility: Visibility, main_origin: (u16, u16), popup_origin: Option<(u16, u16)>, - cell_size: HostCellSize, - occlusion: &Occlusion, -) -> Option<HostPlacement> { +) -> Option<(u16, u16)> { let origin = match (&placement.asset.source, visibility) { ( SurfaceGraphicsSource::Terminal { @@ -758,9 +860,43 @@ fn client_host_placement( } _ => return None, }; - if occlusion.covers(placement, origin, cell_size) { - return None; - } + Some(origin) +} + +fn client_host_piece( + scope: &str, + placement: &SurfaceGraphicsPlacement, + origin: (u16, u16), + cell_size: HostCellSize, + piece: GridPiece, + piece_index: u32, +) -> HostPlacement { + let source_width = if placement.source_width == 0 { + placement.asset.image_width + } else { + placement.source_width + }; + let source_height = if placement.source_height == 0 { + placement.asset.image_height + } else { + placement.source_height + }; + let (source_x, piece_source_width) = piece_source_span( + piece.col, + piece.cols, + placement.cols, + cell_size.width_px, + placement.x_offset, + source_width, + ); + let (source_y, piece_source_height) = piece_source_span( + piece.row, + piece.rows, + placement.rows, + cell_size.height_px, + placement.y_offset, + source_height, + ); let source_key = HostSourceKey::ClientSurface { scope: scope.to_owned(), source: placement.asset.source.clone(), @@ -779,26 +915,41 @@ fn client_host_placement( let raw = hasher.finish(); let pane_id = PaneId::from_raw((raw as u32).max(1)); let host_image_id = host_image_id(scope, &placement.asset); - let cols = placement.cols.min(u32::from(u16::MAX)) as u16; - let rows = placement.rows.min(u32::from(u16::MAX)) as u16; - Some(HostPlacement { + let cols = piece.cols.min(u32::from(u16::MAX)) as u16; + let rows = piece.rows.min(u32::from(u16::MAX)) as u16; + HostPlacement { raw_data: None, pane_id, host_image_id: Some(host_image_id), area: Rect::new( - origin.0.saturating_add(placement.x), - origin.1.saturating_add(placement.y), + origin + .0 + .saturating_add(placement.x) + .saturating_add(piece.col.min(u32::from(u16::MAX)) as u16), + origin + .1 + .saturating_add(placement.y) + .saturating_add(piece.row.min(u32::from(u16::MAX)) as u16), cols, rows, ), cell_size, source_key, placement: KittyImagePlacement { - image_id: 1, + // Only feeds the host placement id, so each piece gets its own. + image_id: 1 + piece_index, placement_id: placement.logical_placement_id, z: placement.z, - x_offset: placement.x_offset, - y_offset: placement.y_offset, + x_offset: if piece.col == 0 { + placement.x_offset + } else { + 0 + }, + y_offset: if piece.row == 0 { + placement.y_offset + } else { + 0 + }, image_width: placement.asset.image_width, image_height: placement.asset.image_height, format: match placement.asset.format { @@ -811,20 +962,47 @@ fn client_host_placement( source_file: None, data: Vec::new(), render: KittyPlacementRenderInfo { - pixel_width: placement.cols.saturating_mul(cell_size.width_px), - pixel_height: placement.rows.saturating_mul(cell_size.height_px), - grid_cols: placement.cols, - grid_rows: placement.rows, + pixel_width: piece.cols.saturating_mul(cell_size.width_px), + pixel_height: piece.rows.saturating_mul(cell_size.height_px), + grid_cols: piece.cols, + grid_rows: piece.rows, viewport_col: 0, viewport_row: 0, - source_x: placement.source_x, - source_y: placement.source_y, - source_width: placement.source_width, - source_height: placement.source_height, + source_x: placement.source_x.saturating_add(source_x), + source_y: placement.source_y.saturating_add(source_y), + source_width: piece_source_width, + source_height: piece_source_height, }, }, scrollback_offset: placement.scrollback_offset, - }) + } +} + +/// Maps a piece's cells onto the source pixels shown there. The leading pixel +/// offset shrinks the image inside its cells, and cuts use absolute boundaries so +/// adjacent pieces share exact source edges. +fn piece_source_span( + start_cell: u32, + cells: u32, + total_cells: u32, + cell_px: u32, + offset: u32, + source_len: u32, +) -> (u32, u32) { + let display = u64::from(total_cells) * u64::from(cell_px); + let offset = u64::from(offset).min(display.saturating_sub(1)); + let edge = |cell: u32| { + let pixel = (u64::from(cell) * u64::from(cell_px)).max(offset); + ((pixel - offset) * u64::from(source_len) / (display - offset).max(1)) as u32 + }; + let start = edge(start_cell); + let end = edge(start_cell + cells); + if end > start { + (start, end - start) + } else { + // An upscaled sliver still needs one source pixel; zero means "whole image". + (start.min(source_len.saturating_sub(1)), 1) + } } fn format_code(format: SurfaceGraphicsFormat) -> u32 { @@ -1265,11 +1443,7 @@ mod tests { } #[test] - fn occlusion_uses_final_pixel_bounds_and_strict_overlap() { - let cell = HostCellSize { - width_px: 8, - height_px: 16, - }; + fn occlusion_uses_placement_cells_and_strict_overlap() { let image = asset( SurfaceGraphicsTarget::Pane { pane_id: "pane".into(), @@ -1281,19 +1455,18 @@ mod tests { let mut cover = Occlusion::default(); cover.cover(Rect::new(12, 8, 1, 1)); // The image at (11, 7) just touches the cover's top-left corner. - assert!(!cover.covers(&placement, (10, 5), cell)); + assert!(!cover.covers(&placement, (10, 5))); + // Pixel offsets shrink the image inside its cells instead of bleeding. placement.x_offset = 1; - assert!(!cover.covers(&placement, (10, 5), cell)); placement.y_offset = 1; - assert!(cover.covers(&placement, (10, 5), cell)); - placement.x_offset = 0; - assert!(!cover.covers(&placement, (10, 5), cell)); + assert!(!cover.covers(&placement, (10, 5))); placement.cols = 2; - assert!(cover.covers(&placement, (10, 5), cell)); - assert!(!cover.covers(&placement, (0, 0), cell)); + placement.rows = 2; + assert!(cover.covers(&placement, (10, 5))); + assert!(!cover.covers(&placement, (0, 0))); cover = Occlusion::default(); cover.cover(Rect::new(11, 7, 0, 1)); - assert!(!cover.covers(&placement, (10, 5), cell)); + assert!(!cover.covers(&placement, (10, 5))); } #[test] @@ -1357,12 +1530,232 @@ mod tests { } } + fn grid_image(cols: u32, rows: u32) -> SurfaceGraphicsScene { + let mut image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "pane".into(), + }, + 1, + vec![0; (cols * 10 * rows * 10 * 4) as usize], + ); + image.key.image_width = cols * 10; + image.key.image_height = rows * 10; + let mut graphics = scene(image, 0, 0); + let placement = &mut graphics.placements[0]; + placement.cols = cols; + placement.rows = rows; + placement.source_width = cols * 10; + placement.source_height = rows * 10; + graphics + } + + fn placed_pieces(bytes: &[u8]) -> Vec<String> { + let text = String::from_utf8_lossy(bytes); + let mut pieces = text + .split("\u{1b}[") + .filter(|chunk| chunk.contains("a=p,")) + .map(|chunk| { + let (cursor, rest) = chunk.split_once('H').unwrap(); + let control = rest + .trim_start_matches("\u{1b}_G") + .split(';') + .next() + .unwrap() + .split(',') + .filter(|field| { + ["c=", "r=", "x=", "y=", "w=", "h=", "X=", "Y="] + .iter() + .any(|key| field.starts_with(key)) + }) + .collect::<Vec<_>>() + .join(","); + format!("{cursor} {control}") + }) + .collect::<Vec<_>>(); + pieces.sort(); + pieces + } + + fn encode_with_cover( + graphics: SurfaceGraphicsScene, + covers: &[Rect], + ) -> (ClientState, Vec<String>) { + let mut state = ClientState::default(); + state.set_scope("crop"); + let _ = state.take_pending_cleanup(); + state.set_scene(graphics); + let mut occlusion = Occlusion::default(); + for rect in covers { + occlusion.cover(*rect); + } + let bytes = state.encode( + Visibility::Main, + (0, 0), + None, + HostCellSize { + width_px: 8, + height_px: 16, + }, + &occlusion, + ); + (state, placed_pieces(&bytes)) + } + #[test] - fn popup_occlusion_respects_draw_order_and_its_own_images() { + fn overlay_on_image_corner_keeps_the_rest_visible() { + // A 10x4-cell image with 10px source pixels per cell; a toast covers + // the top-right 4x2 cells. + let (_, pieces) = encode_with_cover(grid_image(10, 4), &[Rect::new(6, 0, 4, 2)]); + assert_eq!( + pieces, + vec![ + "1;1 c=6,r=2,w=60,h=20".to_owned(), + "3;1 c=10,r=2,y=20,w=100,h=20".to_owned(), + ] + ); + } + + #[test] + fn overlay_inside_image_keeps_four_surrounding_pieces() { + let (_, pieces) = encode_with_cover(grid_image(10, 6), &[Rect::new(3, 2, 4, 2)]); + assert_eq!( + pieces, + vec![ + "1;1 c=10,r=2,w=100,h=20".to_owned(), + "3;1 c=3,r=2,y=20,w=30,h=20".to_owned(), + "3;8 c=3,r=2,x=70,y=20,w=30,h=20".to_owned(), + "5;1 c=10,r=2,y=40,w=100,h=20".to_owned(), + ] + ); + } + + #[test] + fn overlay_across_image_edge_crops_only_the_covered_side() { + let (_, pieces) = encode_with_cover(grid_image(10, 4), &[Rect::new(7, 0, 20, 20)]); + assert_eq!(pieces, vec!["1;1 c=7,r=4,w=70,h=40".to_owned()]); + } + + #[test] + fn multiple_overlays_crop_around_each_other() { + let (_, pieces) = encode_with_cover( + grid_image(10, 4), + &[Rect::new(0, 0, 2, 1), Rect::new(8, 3, 2, 1)], + ); + assert_eq!( + pieces, + vec![ + "1;3 c=8,r=1,x=20,w=80,h=10".to_owned(), + "2;1 c=10,r=2,y=10,w=100,h=20".to_owned(), + "4;1 c=8,r=1,y=30,w=80,h=10".to_owned(), + ] + ); + } + + #[test] + fn pixel_offsets_stay_on_the_leading_pieces_without_seams() { + let mut graphics = grid_image(10, 4); + graphics.placements[0].x_offset = 4; + graphics.placements[0].y_offset = 8; + // Offsets shrink the image inside its cells: 100x40 source pixels span + // 76x56 display pixels starting at (4, 8). + let (_, pieces) = encode_with_cover(graphics.clone(), &[Rect::new(1, 0, 1, 4)]); + assert_eq!( + pieces, + vec![ + "1;1 c=1,r=4,w=5,h=40,X=4,Y=8".to_owned(), + "1;3 c=8,r=4,x=15,w=85,h=40,Y=8".to_owned(), + ] + ); + let (_, pieces) = encode_with_cover(graphics, &[Rect::new(0, 1, 10, 1)]); + assert_eq!( + pieces, + vec![ + "1;1 c=10,r=1,w=100,h=5,X=4,Y=8".to_owned(), + "3;1 c=10,r=2,y=17,w=100,h=23,X=4".to_owned(), + ] + ); + } + + #[test] + fn upscaled_slivers_never_fall_back_to_the_whole_source() { + let mut graphics = grid_image(10, 1); + let placement = &mut graphics.placements[0]; + placement.source_x = 30; + placement.source_width = 2; + let (_, pieces) = encode_with_cover(graphics, &[Rect::new(1, 0, 9, 1)]); + assert_eq!(pieces, vec!["1;1 c=1,r=1,x=30,w=1,h=10".to_owned()]); + } + + #[test] + fn uneven_source_scaling_leaves_no_seam_between_pieces() { + let mut graphics = grid_image(3, 1); + graphics.placements[0].source_width = 10; + let (_, pieces) = encode_with_cover(graphics, &[Rect::new(1, 0, 1, 1)]); + assert_eq!( + pieces, + vec![ + "1;1 c=1,r=1,w=3,h=10".to_owned(), + "1;3 c=1,r=1,x=6,w=4,h=10".to_owned(), + ] + ); + } + + #[test] + fn over_budget_frames_hide_touched_images_whole() { + // A 40x4 image with holes in every other cell of two rows needs 42 + // pieces, so the frame falls back to hiding every touched image. + let mut graphics = grid_image(40, 4); + let fragmented = graphics.placements[0].clone(); + let mut cropped = fragmented.clone(); + cropped.logical_placement_id = 4; + cropped.y = 10; + cropped.cols = 10; + let mut untouched = cropped.clone(); + untouched.logical_placement_id = 5; + untouched.x = 20; + graphics.placements.extend([cropped, untouched]); + let mut covers = (0..40) + .map(|index| Rect::new(index % 20 * 2, index / 20 * 2 + 1, 1, 1)) + .collect::<Vec<_>>(); + covers.push(Rect::new(6, 10, 4, 2)); + let (state, pieces) = encode_with_cover(graphics.clone(), &covers); + assert_eq!(pieces, vec!["11;21 c=10,r=4,w=400,h=40".to_owned()]); + assert_eq!(state.host.placements.len(), 1); + + // Without the fragmenting holes the same frame crops normally. + let (_, pieces) = encode_with_cover(graphics, &covers[40..]); + assert_eq!(pieces.len(), 4, "{pieces:?}"); + } + + #[test] + fn moving_overlay_replaces_and_retires_pieces() { + let mut state = ClientState::default(); + state.set_scope("crop"); + let _ = state.take_pending_cleanup(); + state.set_scene(grid_image(10, 6)); let cell = HostCellSize { width_px: 8, height_px: 16, }; + let mut cover = Occlusion::default(); + cover.cover(Rect::new(3, 2, 4, 2)); + let _ = state.encode(Visibility::Main, (0, 0), None, cell, &cover); + assert_eq!(state.host.placements.len(), 4); + let mut cover = Occlusion::default(); + cover.cover(Rect::new(6, 0, 4, 2)); + let moved = state.encode(Visibility::Main, (0, 0), None, cell, &cover); + assert!(!String::from_utf8_lossy(&moved).contains("a=t")); + assert_eq!(state.host.placements.len(), 2); + let restored = state.encode(Visibility::Main, (0, 0), None, cell, &Occlusion::default()); + assert_eq!( + placed_pieces(&restored), + vec!["1;1 c=10,r=6,w=100,h=60".to_owned()] + ); + assert_eq!(state.host.placements.len(), 1); + } + + #[test] + fn popup_occlusion_respects_draw_order_and_its_own_images() { let image = asset( SurfaceGraphicsTarget::Pane { pane_id: "pane".into(), @@ -1382,11 +1775,11 @@ mod tests { let mut cover = Occlusion::default(); cover.cover(Rect::new(10, 5, 3, 3)); cover.start_popup(Rect::new(9, 4, 5, 5)); - assert!(cover.covers(&main, (10, 5), cell)); - assert!(!cover.covers(&main, (0, 0), cell)); - assert!(!cover.covers(&popup, (10, 5), cell)); + assert!(cover.covers(&main, (10, 5))); + assert!(!cover.covers(&main, (0, 0))); + assert!(!cover.covers(&popup, (10, 5))); cover.cover(Rect::new(10, 5, 1, 1)); - assert!(cover.covers(&popup, (10, 5), cell)); + assert!(cover.covers(&popup, (10, 5))); } #[test] @@ -1410,15 +1803,39 @@ mod tests { graphics.assets.extend(pane.assets); graphics.placements.extend(pane.placements); } - for covered in [false, true] { + for overlays in ["none", "disjoint", "toast_and_menu", "fragmenting"] { let mut state = ClientState::default(); state.set_scope("profile"); - state.set_scene(graphics.clone()); let mut cover = Occlusion::default(); - if covered { - cover.cover(Rect::new(0, 10, 80, 3)); - cover.cover(Rect::new(60, 0, 20, 5)); + let mut graphics = graphics.clone(); + match overlays { + "disjoint" => { + cover.cover(Rect::new(0, 10, 80, 3)); + cover.cover(Rect::new(60, 0, 20, 5)); + } + "toast_and_menu" | "fragmenting" => { + for (index, placement) in graphics.placements.iter_mut().enumerate() { + let x = index as u16 * 16; + placement.x = x; + placement.cols = 16; + placement.rows = 8; + if overlays == "fragmenting" { + // Six holes per 16x8 image leave 11 visible pieces each. + for (col, row) in [(3, 2), (8, 2), (13, 2), (3, 5), (8, 5), (13, 5)] + { + cover.cover(Rect::new(x + col, row, 2, 1)); + } + } + } + if overlays == "toast_and_menu" { + let width = count * 16; + cover.cover(Rect::new(width.saturating_sub(12), 0, 12, 3)); + cover.cover(Rect::new(width / 2, 2, 10, 4)); + } + } + _ => {} } + state.set_scene(graphics); let _ = state.encode_output(Visibility::Main, (0, 0), None, cell, &cover); let mut samples = Vec::new(); for _ in 0..101 { @@ -1436,7 +1853,7 @@ mod tests { } samples.sort_unstable(); eprintln!( - "graphics occlusion panes={count} disjoint_overlays={covered} median_ns={} p95_ns={}", + "graphics occlusion panes={count} overlays={overlays} median_ns={} p95_ns={}", samples[50], samples[95] ); } @@ -1488,6 +1905,54 @@ mod tests { ); } } + // Unicode placeholder images (e.g. a terminal browser) arrive as one + // placement per row, so overlays split only the rows they cross. + let image = asset( + SurfaceGraphicsTarget::Pane { + pane_id: "browser".into(), + }, + 1, + vec![1, 2, 3, 4], + ); + let mut browser = scene(image, 0, 0); + let row = browser.placements.remove(0); + browser.placements = (0..45) + .map(|index| { + let mut row = row.clone(); + row.logical_placement_id = index + 1; + row.y = index as u16; + row.cols = 150; + row + }) + .collect(); + for overlays in [false, true] { + let mut state = ClientState::default(); + state.set_scope("browser-profile"); + state.set_scene(browser.clone()); + let mut cover = Occlusion::default(); + if overlays { + cover.cover(Rect::new(110, 0, 40, 4)); + cover.cover(Rect::new(60, 20, 30, 8)); + } + let _ = state.encode_output(Visibility::Main, (0, 0), None, cell, &cover); + let mut samples = Vec::new(); + for _ in 0..101 { + let start = std::time::Instant::now(); + std::hint::black_box(state.encode_output( + Visibility::Main, + (0, 0), + None, + cell, + &cover, + )); + samples.push(start.elapsed().as_nanos()); + } + samples.sort_unstable(); + eprintln!( + "graphics placeholder rows=45 toast_and_menu={overlays} median_ns={} p95_ns={}", + samples[50], samples[95] + ); + } } #[test] From c34dd6b2acd432cb7acbe9de568b245853e3b8c7 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Sat, 26 Sep 2026 17:15:50 +0200 Subject: [PATCH 68/99] fix: create a symlink on non-elevated windows in docs test (#4654) * fix: create a symlink on non-elevated windows in docs test Windows terminals without Developer Mode or elevation cannot create file symlinks, so the versions publishing integration test failed with EPERM before it could reach the non-ordinary-file rejection. Fall back to a directory junction, which is creatable without elevation and hits the same rejection path. * fix: detect docs snapshot reparse points with lstat Classify entries with lstat instead of the readdir dirent so symlinks and Windows junctions are rejected regardless of how a runtime classifies them. This keeps the versions integration test's non-ordinary-file assertion meaningful on non-elevated Windows, where the fixture can only create a directory junction. --- scripts/docs/snapshot.mjs | 14 +++++++++----- scripts/docs/versions.integration.test.ts | 17 ++++++++++++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/scripts/docs/snapshot.mjs b/scripts/docs/snapshot.mjs index e9eaf92c..7610715a 100644 --- a/scripts/docs/snapshot.mjs +++ b/scripts/docs/snapshot.mjs @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { dirname, relative, resolve } from 'node:path'; export function createGit(repoRoot) { @@ -92,10 +92,14 @@ export async function compareGitTree(git, ref, sourceRoot, snapshotRoot) { export async function listDocumentationPaths(root) { const paths = []; async function walk(directory) { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = resolve(directory, entry.name); - if (entry.isDirectory()) await walk(path); - else if (entry.isFile()) paths.push(relative(root, path)); + for (const name of await readdir(directory)) { + const path = resolve(directory, name); + // lstat does not follow links, so symlinks and Windows junctions stay + // non-ordinary here even if a runtime classifies them as directories in + // the readdir entry. + const stats = await lstat(path); + if (stats.isDirectory()) await walk(path); + else if (stats.isFile()) paths.push(relative(root, path)); else throw new Error(`${path} is not an ordinary documentation file`); } } diff --git a/scripts/docs/versions.integration.test.ts b/scripts/docs/versions.integration.test.ts index 9731d3c0..214bfef9 100644 --- a/scripts/docs/versions.integration.test.ts +++ b/scripts/docs/versions.integration.test.ts @@ -100,9 +100,20 @@ describe('documentation release publishing', () => { correctedArchivedDocs, ); const linkedPage = resolve(archivedDocsRoot, 'linked.mdx'); - await symlink('index.mdx', linkedPage); - expect(() => runScript(root, ['check'])).toThrow(); - await rm(linkedPage); + if (process.platform === 'win32') { + // A normal Windows terminal cannot create file symlinks without Developer + // Mode or elevation, but a directory junction is creatable without + // either. Link to an empty directory outside the snapshot so the walk + // stays finite and only the non-ordinary-file rejection can satisfy the + // assertion below. + const linkTarget = await mkdtemp(join(tmpdir(), 'herdr-docs-link-')); + temporaryDirectories.push(linkTarget); + await symlink(linkTarget, linkedPage, 'junction'); + } else { + await symlink('index.mdx', linkedPage); + } + expect(() => runScript(root, ['check'])).toThrow(/is not an ordinary documentation file/); + await rm(linkedPage, { recursive: true, force: true }); runScript(root, ['check']); delete archivedManifest.versions[0].commit; From bd69871d51d683e27c2e7db905704a5964fd94eb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson <david@hey.com> Date: Sat, 26 Sep 2026 19:17:55 +0200 Subject: [PATCH 69/99] fix: refresh host palette on terminal redraw (#4349) * fix: refresh host palette on terminal redraw * fix: count overlapping host palette queries --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com> --- .../src/content/docs/configuration.mdx | 2 + src/client/input.rs | 11 ++- src/client/mod.rs | 12 +++- src/raw_input.rs | 35 +++++++++- tests/client_mode.rs | 67 +++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 0d68a0f4..1a0ce53c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -248,6 +248,8 @@ name = "catppuccin" Search `theme.name` in the [Config reference](/docs/config-reference/) for every built-in theme. Use `terminal` when you want Herdr UI colors to follow your host terminal's ANSI palette. +On Unix, Herdr also re-reads the host terminal's colors when redrawing after a resize or `SIGWINCH`. If your theme switcher changes terminal colors through OSC without a light/dark notification, send `kill -WINCH <client-pid>` after applying the new palette. Target the attached Herdr client, not the server; existing panes stay running. Direct `herdr terminal attach` sessions leave palette queries to the attached application. + To let Herdr switch its own UI theme when the host terminal reports a light/dark appearance change, enable theme auto-switching: ```toml diff --git a/src/client/input.rs b/src/client/input.rs index 23067576..1e308d54 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -10,7 +10,7 @@ //! - We avoid duplicating parsing logic in the client //! - Host terminal control replies can be buffered or discarded before they leak -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; #[cfg(unix)] @@ -39,6 +39,7 @@ pub fn stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_theme_query_pending: Arc<AtomicU32>, host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, host_sgr_pixels_active: Arc<AtomicBool>, @@ -51,6 +52,7 @@ pub fn stdin_reader_loop( { let _ = ( host_color_query_sent, + host_theme_query_pending, host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, @@ -64,6 +66,7 @@ pub fn stdin_reader_loop( event_tx, should_quit, host_color_query_sent, + host_theme_query_pending, host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, @@ -79,6 +82,7 @@ fn unix_stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_theme_query_pending: Arc<AtomicU32>, host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, host_sgr_pixels_active: Arc<AtomicBool>, @@ -178,6 +182,11 @@ fn unix_stdin_reader_loop( match reader.read(&mut scratch) { Ok(0) => break, Ok(n) => { + // A redraw can issue queries while this thread is blocked in read(). + // Arm the split-reply guard before framing the returned bytes. + for _ in 0..host_theme_query_pending.swap(0, Ordering::AcqRel) { + framer.host_color_query_sent(); + } let sgr_pixels = *pending_mode .get_or_insert_with(|| host_sgr_pixels_active.load(Ordering::Acquire)); if sgr_pixels { diff --git a/src/client/mod.rs b/src/client/mod.rs index 1a0f8dd5..2cec6d2d 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -128,7 +128,7 @@ use terminal_sessions::terminal_control_command_from_json; #[cfg(unix)] use std::collections::HashMap; use std::io::{self, Write as _}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; #[cfg(unix)] use std::sync::Mutex; @@ -509,6 +509,8 @@ async fn run_client_loop( // Spawn the stdin reader thread. let will_query_host_terminal_theme = state.attach_escape.is_none() && should_query_host_terminal_theme(); + let host_theme_query_pending = Arc::new(AtomicU32::new(0)); + let stdin_host_theme_query_pending = host_theme_query_pending.clone(); // Terminals behind ConPTY report no pixel size through the ioctl, so ask the // host terminal directly instead of falling back to an assumed cell size. let will_query_host_cell_size = state.attach_escape.is_none() @@ -530,6 +532,7 @@ async fn run_client_loop( stdin_tx, &stdin_quit, will_query_host_terminal_theme, + stdin_host_theme_query_pending, will_query_host_cell_size, stdin_mouse_capture_active, stdin_sgr_pixels_active, @@ -1181,6 +1184,13 @@ async fn run_client_loop( cell_height_px, pixel_geometry_exact, ) => { + // Palette updates through OSC do not necessarily produce a color-scheme + // notification. Re-query on redraw, including SIGWINCH without a resize, + // so desktop theme switchers can refresh the existing panes in place. + if will_query_host_terminal_theme { + host_theme_query_pending.fetch_add(1, Ordering::AcqRel); + query_host_terminal_theme(); + } if !pixel_geometry_exact && host_sgr_pixels_active.load(Ordering::Acquire) { set_mouse_capture(state.mouse_capture_active, false) .map_err(ClientError::ConnectionFailed)?; diff --git a/src/raw_input.rs b/src/raw_input.rs index b2c3beda..2694912c 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -179,8 +179,11 @@ impl RawInputByteFramer { /// Hold a lone trailing ESC for one idle flush so an OSC 10/11 reply split /// at its ESC introducer stitches back together instead of leaking (#549). + /// Overlapping queries each add their own replies to the window. pub(crate) fn host_color_query_sent(&mut self) { - self.host_color_replies_awaited = HOST_COLOR_QUERY_REPLIES; + self.host_color_replies_awaited = self + .host_color_replies_awaited + .saturating_add(HOST_COLOR_QUERY_REPLIES); self.held_pending_host_reply_esc = false; } @@ -2859,4 +2862,34 @@ mod tests { assert!(framer.push(b"\x1b").is_empty()); assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); } + + #[test] + fn overlapping_host_color_queries_keep_holding_split_reply_escape() { + use std::fmt::Write as _; + + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + framer.host_color_query_sent(); + let mut first_batch = + String::from("\x1b]10;rgb:6565/7b7b/8383\x1b\\\x1b]11;rgb:2424/2727/3a3a\x1b\\"); + for index in 0..=u8::MAX { + let _ = write!(first_batch, "\x1b]4;{index};rgb:1111/2222/3333\x1b\\"); + } + assert_eq!(framer.push(first_batch.as_bytes()).len(), 258); + + // The second batch is still outstanding, so its first reply split at ESC + // must not leak into the pane as an Escape key. + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let chunks = framer.push(b"]10;rgb:eeee/eeee/eeee\x1b\\"); + assert_eq!(chunks.len(), 1); + let (event, _) = extract_one_event(&chunks[0]).unwrap(); + assert!(matches!( + event, + RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + } + )); + } } diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 9bcb4b2c..b5532317 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -714,6 +714,73 @@ fn read_output(output: &SharedOutput) -> String { .clone() } +#[test] +fn sigwinch_refreshes_host_palette_without_resizing() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let server = spawn_server_with_config( + &config_home, + &runtime_dir, + &api_socket, + &client_socket, + "onboarding = false\n[theme]\nname = \"terminal\"\n", + ); + wait_for_socket(&api_socket, Duration::from_secs(10)); + wait_for_socket(&client_socket, Duration::from_secs(10)); + let client = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let master = client._master.as_ref().expect("client PTY"); + let output = spawn_pty_drain(master.try_clone_reader().unwrap()); + let mut writer = master.take_writer().unwrap(); + let queries = "\x1b]10;?\x1b\\\x1b]11;?\x1b\\"; + assert!( + wait_until(Duration::from_secs(5), Duration::from_millis(25), || { + read_output(&output).contains(queries) + }), + "client should query the initial palette: {:?}", + read_output(&output) + ); + + // Complete the startup query with a light palette. No appearance notification + // is sent: this models a terminal whose colors are changed directly by OSC. + let mut light = + String::from("\x1b]10;rgb:0000/0000/0000\x1b\\\x1b]11;rgb:ffff/ffff/ffff\x1b\\"); + for index in 0..=u8::MAX { + light.push_str(&format!("\x1b]4;{index};rgb:ffff/ffff/ffff\x1b\\")); + } + writer.write_all(light.as_bytes()).unwrap(); + writer.flush().unwrap(); + // Let startup settle and the resize watcher install its signal handler. + thread::sleep(Duration::from_millis(250)); + + for _ in 0..2 { + let watermark = output_len(&output); + assert_eq!( + unsafe { libc::kill(client.child.process_id().unwrap() as i32, libc::SIGWINCH) }, + 0 + ); + assert!( + wait_until(Duration::from_secs(5), Duration::from_millis(25), || { + let captured = read_output(&output); + let refreshed = &captured[watermark..]; + refreshed.contains(queries) && refreshed.contains("\x1b]4;15;?\x1b\\") + }), + "SIGWINCH should query default colors and the ANSI palette without changing PTY size" + ); + writer + .write_all(b"\x1b]10;rgb:eeee/eeee/eeee\x1b\\\x1b]11;rgb:1111/2222/3333\x1b\\") + .unwrap(); + writer.flush().unwrap(); + } + + drop(writer); + drop(client); + cleanup_spawned_herdr(server, base); +} + /// Current captured byte length, used as a watermark so a test can search only /// the output emitted *after* a trigger. The teardown markers also appear in /// normal attach-phase output, so matching the whole buffer is meaningless. From ccd51946fd3b6da307259460f09755ef07be69f6 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sat, 26 Sep 2026 20:19:10 +0300 Subject: [PATCH 70/99] refactor: encode kitty placements in a single pass (#4657) --- src/kitty_graphics.rs | 174 ++++++++++------------------------ src/kitty_graphics/surface.rs | 14 +-- 2 files changed, 56 insertions(+), 132 deletions(-) diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index 6bca9443..e69b9df3 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -106,7 +106,6 @@ pub(crate) struct HostGraphicsCache { placements: HashMap<(u32, u32), PlacementSignature>, /// Host image currently backing each (pane, source image id) pair. sources: HashMap<HostSourceKey, u32>, - continuation: Option<(HostSourceKey, u32, usize)>, replay_placements: bool, replayed_placements: HashSet<(u32, u32)>, } @@ -121,25 +120,12 @@ pub(crate) fn is_enabled() -> bool { KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire) } -#[cfg(test)] -pub(crate) struct EncodedGraphics { - pub(crate) bytes: Vec<u8>, - pub(crate) incomplete: bool, -} - pub(crate) fn image_transfer_estimated_size(data_len: usize) -> usize { let encoded = data_len.div_ceil(3).saturating_mul(4); let command_overhead = data_len.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; encoded.saturating_add(command_overhead) } -fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { - ( - placement.source_key.clone(), - host_placement_id(&placement.source_key, &placement.placement), - ) -} - fn encode_placement_update( cache: &mut HostGraphicsCache, placement: &HostPlacement, @@ -166,22 +152,29 @@ fn encode_placement_update( let mut bytes = Vec::new(); let mut output = GraphicsOutput::default(); if !image_current { + // Bail out before touching the cache so a pending upload keeps the old image. + if placement + .raw_data + .as_deref() + .unwrap_or(&placement.placement.data) + .is_empty() + { + return None; + } if cache.images.contains_key(&host_id) { encode_delete_image(&mut bytes, host_id); cache.placements.retain(|(id, _), _| *id != host_id); cache.replayed_placements.retain(|(id, _)| *id != host_id); } if let Some(data) = &placement.raw_data { - if data.is_empty() { - return None; - } output.push_bytes(std::mem::take(&mut bytes)); output.operations.push(GraphicsOperation::Upload { control: upload_control(placement, format_code, host_id), data: Arc::clone(data), }); - } else if !encode_upload_image(&mut bytes, placement, format_code, host_id) { - return None; + } else { + let control = upload_control(placement, format_code, host_id); + encode_kitty_data(&mut bytes, &control, &placement.placement.data); } cache.images.insert(host_id, image_signature); } @@ -222,22 +215,11 @@ fn release_superseded_source_image( cache.replayed_placements.retain(|(id, _)| *id != previous); } -#[cfg(test)] -fn encode_graphics_update_incremental( +/// Encodes every placement change for one frame in a single linear pass. +fn encode_graphics_output( cache: &mut HostGraphicsCache, placements: &[HostPlacement], -) -> EncodedGraphics { - let (output, incomplete) = encode_graphics_output_incremental(cache, placements); - EncodedGraphics { - bytes: output.into_inline_bytes(), - incomplete, - } -} - -fn encode_graphics_output_incremental( - cache: &mut HostGraphicsCache, - placements: &[HostPlacement], -) -> (GraphicsOutput, bool) { +) -> GraphicsOutput { let desired_sources = placements .iter() .map(|placement| placement.source_key.clone()) @@ -256,24 +238,11 @@ fn encode_graphics_output_incremental( }) }) .collect::<HashSet<_>>(); - let start = cache - .continuation - .as_ref() - .and_then(|(source, id, _)| { - placements - .iter() - .position(|placement| placement_identity(placement) == (source.clone(), *id)) - }) - .map(|index| index + 1) - .or_else(|| cache.continuation.as_ref().map(|cursor| cursor.2)) - .map_or(0, |index| index % placements.len().max(1)); - let mut bytes = GraphicsOutput::default(); - let mut emitted = false; - cache .sources .retain(|source, _| desired_sources.contains(source)); + let mut output = GraphicsOutput::default(); let mut stale = cache .placements .keys() @@ -282,59 +251,24 @@ fn encode_graphics_output_incremental( .collect::<Vec<_>>(); stale.sort_unstable(); for key @ (host_id, placement_id) in stale { - let mut transaction = Vec::new(); - encode_delete_placement(&mut transaction, host_id, placement_id); - if emitted { - return (bytes, true); - } - bytes.push_bytes(transaction); + let mut bytes = Vec::new(); + encode_delete_placement(&mut bytes, host_id, placement_id); + output.push_bytes(bytes); cache.placements.remove(&key); cache.replayed_placements.remove(&key); - emitted = true; } - - // Preserve one cache mutation transaction per incremental call. - for offset in 0..placements.len() { - let index = (start + offset) % placements.len(); - let placement = &placements[index]; - let mut candidate = cache.clone(); - let Some(transaction) = encode_placement_update(&mut candidate, placement) else { - continue; - }; - if transaction.is_empty() { - *cache = candidate; - continue; - } - if emitted { - return (bytes, true); + for placement in placements { + if let Some(transaction) = encode_placement_update(cache, placement) { + output.extend(transaction); } - *cache = candidate; - let (source, id) = placement_identity(placement); - cache.continuation = Some((source, id, (index + 1) % placements.len())); - bytes.extend(transaction); - emitted = true; } - cache.replay_placements = false; cache.replayed_placements.clear(); - (bytes, false) -} - -#[cfg(test)] -fn drain_graphics_updates(cache: &mut HostGraphicsCache, placements: &[HostPlacement]) -> Vec<u8> { - let mut bytes = Vec::new(); - loop { - let encoded = encode_graphics_update_incremental(cache, placements); - bytes.extend(encoded.bytes); - if !encoded.incomplete { - return bytes; - } - } + output } impl HostGraphicsCache { - fn reset_incremental_progress(&mut self) { - self.continuation = None; + fn reset_replay(&mut self) { self.replay_placements = false; self.replayed_placements.clear(); } @@ -354,7 +288,7 @@ impl HostGraphicsCache { self.images.clear(); self.placements.clear(); self.sources.clear(); - self.reset_incremental_progress(); + self.reset_replay(); bytes } } @@ -517,21 +451,6 @@ fn upload_control(placement: &HostPlacement, format_code: u32, host_id: u32) -> ) } -fn encode_upload_image( - out: &mut Vec<u8>, - placement: &HostPlacement, - format_code: u32, - host_id: u32, -) -> bool { - if placement.placement.data.is_empty() { - return false; - } - - let control = upload_control(placement, format_code, host_id); - encode_kitty_data(out, &control, &placement.placement.data); - true -} - fn encode_display_placement( out: &mut Vec<u8>, clipped: ClippedPlacement, @@ -811,21 +730,14 @@ mod tests { second.placement.placement_id += 1; let mut placements = [first, second]; let mut inline_cache = HostGraphicsCache::default(); - let expected = drain_graphics_updates(&mut inline_cache, &placements); + let expected = + encode_graphics_output(&mut inline_cache, &placements).into_inline_bytes(); for placement in &mut placements { placement.placement.data.clear(); placement.raw_data = Some(Arc::clone(&data)); } let mut cache = HostGraphicsCache::default(); - let mut output = GraphicsOutput::default(); - loop { - let (next, incomplete) = - encode_graphics_output_incremental(&mut cache, &placements); - output.extend(next); - if !incomplete { - break; - } - } + let output = encode_graphics_output(&mut cache, &placements); assert_eq!(output.clone().into_inline_bytes(), expected); let uploads = output .operations @@ -894,7 +806,7 @@ mod tests { if replay { cache.request_placement_replay(); } - bytes.extend(drain_graphics_updates(cache, placements)); + bytes.extend(encode_graphics_output(cache, placements).into_inline_bytes()); bytes } @@ -972,6 +884,27 @@ mod tests { } } + #[test] + fn changed_image_without_data_keeps_the_previous_image() { + // Client surfaces keep one host image id per source across revisions. + let placement = || { + let mut placement = test_placement(0, 0); + placement.host_image_id = Some(77); + placement + }; + let mut cache = HostGraphicsCache::default(); + let _ = encode_graphics_output(&mut cache, &[placement()]); + let mut pending = placement(); + pending.placement.data_fingerprint = 43; + pending.placement.data.clear(); + let images = cache.images.clone(); + let placements = cache.placements.clone(); + let output = encode_graphics_output(&mut cache, &[pending]); + assert!(output.into_inline_bytes().is_empty()); + assert_eq!(cache.images, images); + assert_eq!(cache.placements, placements); + } + #[test] fn view_change_redisplays_unchanged_visible_placement() { let mut cache = HostGraphicsCache::default(); @@ -1023,12 +956,9 @@ mod tests { let second = terminal(99).source_key; let mut cache = HostGraphicsCache::default(); for id in 1..=3 { - assert!( - encode_graphics_update_incremental(&mut cache, &[terminal(id), terminal(99)]) - .incomplete - ); + let _ = encode_graphics_output(&mut cache, &[terminal(id), terminal(99)]); + assert!(cache.sources.contains_key(&second)); } - assert!(cache.sources.contains_key(&second)); } #[test] diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index 5c13b6ef..ee6d6e97 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -7,8 +7,8 @@ use super::output::GraphicsOutput; use ratatui::layout::Rect; use super::{ - clipped_placement, collect_visible_placements, encode_graphics_output_incremental, - HostCellSize, HostGraphicsCache, HostPlacement, HostSourceKey, ImageSignature, + clipped_placement, collect_visible_placements, encode_graphics_output, HostCellSize, + HostGraphicsCache, HostPlacement, HostSourceKey, ImageSignature, }; use crate::ghostty::{ KittyImageDescriptor, KittyImageFormat, KittyImagePlacement, KittyPlacementRenderInfo, @@ -527,14 +527,8 @@ impl ClientState { .collect::<Vec<_>>(); let mut output = GraphicsOutput::from_bytes(bytes); self.host.request_placement_replay(); - loop { - let (encoded, incomplete) = - encode_graphics_output_incremental(&mut self.host, &placements); - output.extend(encoded); - if !incomplete { - return output; - } - } + output.extend(encode_graphics_output(&mut self.host, &placements)); + output } } From bde45262be66fc8a95d36e6058b0afd4865ffed5 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sat, 26 Sep 2026 20:59:41 +0300 Subject: [PATCH 71/99] fix: keep api listener alive after transient accept errors (#4659) * fix: keep api listener alive after transient accept errors refs #4601 * fix: drop repeated api accept failure logs refs #4601 --- src/api/server.rs | 138 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 107 insertions(+), 31 deletions(-) diff --git a/src/api/server.rs b/src/api/server.rs index 30d92be4..dbd7d3b6 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -31,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<()>, @@ -118,37 +119,34 @@ fn start_server_inner( 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); - #[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"); - } - }); - } - 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"); }); @@ -160,6 +158,84 @@ fn start_server_inner( }) } +fn run_accept_loop<S>( + incoming: impl IntoIterator<Item = io::Result<S>>, + 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<u32> { + 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<ErrorResponse> { #[derive(serde::Deserialize)] struct RequestMethod { From c33fd40eab3d92b564d7dd25bbfdd462e4b7aba2 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik <ogulcancelik@gmail.com> Date: Sat, 26 Sep 2026 21:49:25 +0300 Subject: [PATCH 72/99] ci: reuse compiler state on every os and save caches only from master --- .github/workflows/ci.yml | 91 +++++++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e773dbc..5471489d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ env: # Persist both Zig caches; local build outputs otherwise stay in the uncached vendor tree. ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache + # PR caches are visible only to their own PR, and every save pushes the + # repository toward GitHub's cache limit, evicting the master caches PRs + # restore from. Only branch pushes save. + SAVE_CACHES: ${{ github.event_name == 'push' }} concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -91,7 +95,17 @@ jobs: with: bun-version: 1.3.14 + # Zig downloads come from ziglang.org or a random community mirror and + # vary from seconds to minutes; reuse the verified install instead. + - name: Restore Zig compiler + id: zig-compiler + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/herdr-zig-0.16.0 + key: zig-compiler-v1-${{ runner.os }}-${{ runner.arch }}-0.16.0 + - name: Install Zig + if: steps.zig-compiler.outputs.cache-hit != 'true' uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 @@ -99,24 +113,39 @@ jobs: # Zig's C-header cache keys include the compiler installation path. - name: Stabilize Zig installation path on Unix if: matrix.kind == 'unix' + env: + ZIG_RESTORED: ${{ steps.zig-compiler.outputs.cache-hit }} run: | stable_zig="$RUNNER_TEMP/herdr-zig-0.16.0" - mv "$(dirname "$(command -v zig)")" "$stable_zig" + if [ "$ZIG_RESTORED" != "true" ]; then + mv "$(dirname "$(command -v zig)")" "$stable_zig" + fi echo "$stable_zig" >> "$GITHUB_PATH" echo "ZIG=$stable_zig/zig" >> "$GITHUB_ENV" - name: Stabilize Zig installation path on Windows if: matrix.kind == 'windows' shell: pwsh + env: + ZIG_RESTORED: ${{ steps.zig-compiler.outputs.cache-hit }} run: | - $source = Split-Path (Get-Command zig).Source $stable = Join-Path $env:RUNNER_TEMP "herdr-zig-0.16.0" - Move-Item -LiteralPath $source -Destination $stable + if ($env:ZIG_RESTORED -ne "true") { + $source = Split-Path (Get-Command zig).Source + Move-Item -LiteralPath $source -Destination $stable + } $stable | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append "ZIG=$(Join-Path $stable 'zig.exe')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Save Zig compiler + if: steps.zig-compiler.outputs.cache-hit != 'true' && env.SAVE_CACHES == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/herdr-zig-0.16.0 + key: zig-compiler-v1-${{ runner.os }}-${{ runner.arch }}-0.16.0 + - name: Restore Zig build cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .zig-cache key: zig-build-v1-${{ runner.os }}-${{ runner.arch }}-0.16.0-${{ github.run_id }}-${{ github.run_attempt }} @@ -128,12 +157,11 @@ jobs: with: cache-bin: false key: ${{ matrix.os }} + save-if: ${{ env.SAVE_CACHES }} - # Save before rust-cache's post-step removes incremental state. Cargo still - # rebuilds workspace targets; only rustc's validated query state is retained. - - name: Restore Windows incremental compiler state - if: matrix.kind == 'windows' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + # Cargo still rebuilds workspace targets; only rustc's validated query state is retained. + - name: Restore incremental compiler state + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: target/debug/incremental key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-${{ github.sha }} @@ -142,7 +170,7 @@ jobs: - name: Run Linux lint if: runner.os == 'Linux' - run: just lint + run: CARGO_INCREMENTAL=1 just lint - name: Run Linux tests if: runner.os == 'Linux' @@ -150,7 +178,7 @@ jobs: - name: Run macOS checks if: runner.os == 'macOS' - run: just ci '${{ matrix.nextest_filter }}' + run: CARGO_INCREMENTAL=1 just ci '${{ matrix.nextest_filter }}' - name: Check scalar libghostty with runtime safety if: runner.os == 'Linux' @@ -174,6 +202,21 @@ jobs: $exe = Join-Path $PWD "target\debug\herdr.exe" .\scripts\windows_smoke_conpty_path.ps1 -ExePath $exe -Session "ci-windows-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + # Save before rust-cache's post-step removes incremental state. + - name: Save incremental compiler state + if: env.SAVE_CACHES == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: target/debug/incremental + key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-${{ github.sha }} + + - name: Save Zig build cache + if: env.SAVE_CACHES == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .zig-cache + key: zig-build-v1-${{ runner.os }}-${{ runner.arch }}-0.16.0-${{ github.run_id }}-${{ github.run_attempt }} + windows-conpty-package: name: Windows ConPTY package runs-on: windows-2022 @@ -190,22 +233,34 @@ jobs: toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} targets: x86_64-pc-windows-msvc + - name: Restore Zig compiler + id: zig-compiler + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/herdr-zig-0.16.0 + key: zig-compiler-v1-${{ runner.os }}-${{ runner.arch }}-0.16.0 + - name: Install Zig + if: steps.zig-compiler.outputs.cache-hit != 'true' uses: vercel-labs/setup-zig@83c1594f26b86da8a9a8ddd9b5ee5f3af0f96943 # v1.0.2 with: version: 0.16.0 - name: Stabilize Zig installation path shell: pwsh + env: + ZIG_RESTORED: ${{ steps.zig-compiler.outputs.cache-hit }} run: | - $source = Split-Path (Get-Command zig).Source $stable = Join-Path $env:RUNNER_TEMP "herdr-zig-0.16.0" - Move-Item -LiteralPath $source -Destination $stable + if ($env:ZIG_RESTORED -ne "true") { + $source = Split-Path (Get-Command zig).Source + Move-Item -LiteralPath $source -Destination $stable + } $stable | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append "ZIG=$(Join-Path $stable 'zig.exe')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Restore Zig build cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .zig-cache key: zig-build-v1-conpty-package-windows-2022-${{ runner.arch }}-0.16.0-${{ github.run_id }}-${{ github.run_attempt }} @@ -217,10 +272,18 @@ jobs: with: cache-bin: false key: conpty-package-windows-2022 + save-if: ${{ env.SAVE_CACHES }} - name: Build Herdr run: cargo build --locked --target x86_64-pc-windows-msvc + - name: Save Zig build cache + if: env.SAVE_CACHES == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .zig-cache + key: zig-build-v1-conpty-package-windows-2022-${{ runner.arch }}-0.16.0-${{ github.run_id }}-${{ github.run_attempt }} + - name: Verify invalid bundle is rejected and system override recovers shell: pwsh run: | From fff6c820aa45f4eabb9b2e0456326dc74cca5a25 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sun, 27 Sep 2026 00:06:29 +0300 Subject: [PATCH 73/99] refactor: extract libghostty-vt binding into ghostty-vt workspace crate (#4661) * refactor: extract libghostty-vt binding into ghostty-vt workspace crate * fix: build libghostty-vt into each build's out dir --- .github/workflows/ci.yml | 10 +- .github/workflows/nix.yml | 2 + .zed/settings.json | 2 +- Cargo.lock | 11 ++ Cargo.toml | 5 + build.rs | 109 -------------- crates/ghostty-vt/Cargo.toml | 14 ++ crates/ghostty-vt/build.rs | 137 ++++++++++++++++++ .../ghostty-vt/src}/bindings.rs | 0 .../mod.rs => crates/ghostty-vt/src/lib.rs | 75 ++++++---- .../ghostty-vt/src}/native_image_sources.rs | 0 .../ghostty-vt/src}/native_source.rs | 0 .../ghostty-vt/src}/native_source_tests.rs | 0 .../ghostty-vt/src}/pane_graphics_files.rs | 55 ++++--- .../ghostty-vt/src}/png_forward_tests.rs | 0 nix/package.nix | 1 + scripts/generate_libghostty_bindings.sh | 2 +- src/app/actions.rs | 4 +- src/main.rs | 4 +- src/pane.rs | 2 +- src/pane/terminal.rs | 7 + src/platform/linux.rs | 3 - src/platform/mod.rs | 13 -- tests/support/terminal_screen.rs | 9 +- 24 files changed, 273 insertions(+), 192 deletions(-) create mode 100644 crates/ghostty-vt/Cargo.toml create mode 100644 crates/ghostty-vt/build.rs rename {src/ghostty => crates/ghostty-vt/src}/bindings.rs (100%) rename src/ghostty/mod.rs => crates/ghostty-vt/src/lib.rs (98%) rename {src/platform/linux => crates/ghostty-vt/src}/native_image_sources.rs (100%) rename {src/ghostty => crates/ghostty-vt/src}/native_source.rs (100%) rename {src/ghostty => crates/ghostty-vt/src}/native_source_tests.rs (100%) rename {src => crates/ghostty-vt/src}/pane_graphics_files.rs (94%) rename {src/ghostty => crates/ghostty-vt/src}/png_forward_tests.rs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5471489d..322fe853 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ env: # Keep file/line backtraces without generating type and variable debug information. CARGO_PROFILE_DEV_DEBUG: "1" CARGO_PROFILE_TEST_DEBUG: "1" - # Persist both Zig caches; local build outputs otherwise stay in the uncached vendor tree. + # Persist both Zig caches; build outputs otherwise stay uncached in Cargo's OUT_DIR. ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache # PR caches are visible only to their own PR, and every save pushes the @@ -164,9 +164,9 @@ jobs: uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: target/debug/incremental - key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-${{ github.sha }} + key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs', 'crates/*/Cargo.toml', 'crates/*/build.rs') }}-${{ github.sha }} restore-keys: | - ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}- + ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs', 'crates/*/Cargo.toml', 'crates/*/build.rs') }}- - name: Run Linux lint if: runner.os == 'Linux' @@ -185,7 +185,7 @@ jobs: env: LIBGHOSTTY_VT_SIMD: "false" LIBGHOSTTY_VT_OPTIMIZE: ReleaseSafe - run: CARGO_INCREMENTAL=1 just test-one ghostty + run: CARGO_INCREMENTAL=1 cargo nextest run --locked -E 'package(ghostty-vt) | test(ghostty)' --status-level fail --final-status-level fail --failure-output final --success-output never - name: Run Windows checks if: matrix.kind == 'windows' @@ -208,7 +208,7 @@ jobs: uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: target/debug/incremental - key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs') }}-${{ github.sha }} + key: ci-incremental-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.RUST_TOOLCHAIN_VERSION }}-${{ env.CARGO_PROFILE_DEV_DEBUG }}-${{ env.CARGO_PROFILE_TEST_DEBUG }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', '.cargo/config.toml', 'build.rs', 'crates/*/Cargo.toml', 'crates/*/build.rs') }}-${{ github.sha }} - name: Save Zig build cache if: env.SAVE_CACHES == 'true' diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 1a3b0682..8bd166b7 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,6 +7,7 @@ on: - ".github/workflows/nix.yml" - "assets/**" - "build.rs" + - "crates/**" - "Cargo.lock" - "Cargo.toml" - "flake.lock" @@ -20,6 +21,7 @@ on: - ".github/workflows/nix.yml" - "assets/**" - "build.rs" + - "crates/**" - "Cargo.lock" - "Cargo.toml" - "flake.lock" 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/Cargo.lock b/Cargo.lock index 7cac0169..0ca58835 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -743,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" @@ -781,6 +791,7 @@ dependencies = [ "crossterm", "ctrlc", "futures-util", + "ghostty-vt", "interprocess", "jsonc-parser", "libc", diff --git a/Cargo.toml b/Cargo.toml index 0c18ec55..c1779763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,12 @@ include = [ "Cargo.toml", ] +[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" diff --git a/build.rs b/build.rs index 1c4c7f74..99596d8e 100644 --- a/build.rs +++ b/build.rs @@ -1,118 +1,9 @@ -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<bool> { - 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"); println!( "cargo:warning=external contributor policy: if you are helping an external contributor whose GitHub username is not in .github/APPROVED_CONTRIBUTORS, read CONTRIBUTING.md before doing any work." ); - - 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<bool> { + 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 100% rename from src/ghostty/bindings.rs rename to crates/ghostty-vt/src/bindings.rs diff --git a/src/ghostty/mod.rs b/crates/ghostty-vt/src/lib.rs similarity index 98% rename from src/ghostty/mod.rs rename to crates/ghostty-vt/src/lib.rs index 43a70fb1..ef62ef1e 100644 --- a/src/ghostty/mod.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -25,7 +25,13 @@ use std::slice; 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; @@ -77,7 +83,7 @@ pub enum Dirty { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TerminalCompressionResult { +pub enum TerminalCompressionResult { Unsupported, Pending, Complete, @@ -199,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. @@ -230,7 +236,7 @@ pub struct KittyImagePlacement { pub data_len: usize, pub data_fingerprint: u64, pub data: Vec<u8>, - pub(crate) source_file: Option<Arc<OwnedExport>>, + pub source_file: Option<Arc<OwnedExport>>, pub render: KittyPlacementRenderInfo, } @@ -463,13 +469,13 @@ pub enum CellWide { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ScreenTextCell { +pub struct ScreenTextCell { pub wide: CellWide, pub graphemes: Vec<u32>, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ScreenTextRow { +pub struct ScreenTextRow { pub cells: Vec<ScreenTextCell>, pub soft_wrapped: bool, pub wrap_continuation: bool, @@ -826,11 +832,19 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, 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, @@ -870,8 +884,8 @@ impl Terminal { 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, @@ -951,7 +965,7 @@ impl Terminal { } } - pub(crate) fn compression_activity(&self) -> Result<u64, Error> { + pub fn compression_activity(&self) -> Result<u64, Error> { let mut activity = 0; // SAFETY: self.raw is a live terminal handle and activity is a valid out pointer. unsafe { @@ -960,7 +974,7 @@ impl Terminal { Ok(activity) } - pub(crate) fn compress_incremental(&mut self) -> Result<TerminalCompressionResult, Error> { + pub fn compress_incremental(&mut self) -> Result<TerminalCompressionResult, Error> { 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. @@ -1240,7 +1254,7 @@ impl Terminal { } #[cfg(windows)] - pub(crate) fn track_row(&mut self, y: u32) -> Option<usize> { + pub fn track_row(&mut self, y: u32) -> Option<usize> { let mut point = ffi::GhosttyPointCoordinate::default(); let tag = ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN; let result = @@ -1262,11 +1276,11 @@ impl Terminal { Ok((wide, graphemes)) } - pub(crate) fn screen_text_rows(&self) -> Result<Vec<ScreenTextRow>, Error> { + pub fn screen_text_rows(&self) -> Result<Vec<ScreenTextRow>, 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, @@ -1317,7 +1331,7 @@ impl Terminal { grid_ref_hyperlink_uri(&grid_ref) } - pub(crate) fn viewport_link_target(&self, x: u16, y: u32) -> Result<Option<LinkTarget>, Error> { + pub fn viewport_link_target(&self, x: u16, y: u32) -> Result<Option<LinkTarget>, Error> { Ok(self .viewport_link_selection(x, y)? .map(|(target, _)| target)) @@ -1390,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<std::ops::Range<usize>>, - ) -> Result<Vec<crate::api::schema::PaneLinkRegion>, Error> { + ) -> Result<Vec<LinkRegion>, Error> { let cols = self.cols()?; let rows = self.rows()?; if x >= cols || y >= u32::from(rows) { @@ -1461,7 +1475,7 @@ impl Terminal { false, )?; let mut byte = prefix.len().saturating_sub(cell_len(&first)?); - let mut regions: Vec<crate::api::schema::PaneLinkRegion> = Vec::new(); + let mut regions: Vec<LinkRegion> = 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 }; @@ -1485,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, @@ -1716,11 +1730,11 @@ impl Terminal { self.get_optional_rgb_color(TERMINAL_DATA_COLOR_CURSOR) } - pub(crate) fn width_px(&self) -> Result<u32, Error> { + pub fn width_px(&self) -> Result<u32, Error> { self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX) } - pub(crate) fn height_px(&self) -> Result<u32, Error> { + pub fn height_px(&self) -> Result<u32, Error> { self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_HEIGHT_PX) } @@ -1805,7 +1819,7 @@ impl Terminal { ) } - pub(crate) fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> { + pub fn kitty_graphics_may_have_placements(&self) -> Result<bool, Error> { let generation = self.kitty_graphics_generation()?; Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation)) } @@ -2460,7 +2474,7 @@ fn kitty_placeholder_diacritic_index(codepoint: u32) -> Option<u32> { // 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() { @@ -3159,6 +3173,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) } @@ -3327,6 +3343,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) } @@ -3989,6 +4006,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() @@ -4000,14 +4019,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/src/platform/linux/native_image_sources.rs b/crates/ghostty-vt/src/native_image_sources.rs similarity index 100% rename from src/platform/linux/native_image_sources.rs rename to crates/ghostty-vt/src/native_image_sources.rs diff --git a/src/ghostty/native_source.rs b/crates/ghostty-vt/src/native_source.rs similarity index 100% rename from src/ghostty/native_source.rs rename to crates/ghostty-vt/src/native_source.rs diff --git a/src/ghostty/native_source_tests.rs b/crates/ghostty-vt/src/native_source_tests.rs similarity index 100% rename from src/ghostty/native_source_tests.rs rename to crates/ghostty-vt/src/native_source_tests.rs diff --git a/src/pane_graphics_files.rs b/crates/ghostty-vt/src/pane_graphics_files.rs similarity index 94% rename from src/pane_graphics_files.rs rename to crates/ghostty-vt/src/pane_graphics_files.rs index 5c272bbe..c5f844d2 100644 --- a/src/pane_graphics_files.rs +++ b/crates/ghostty-vt/src/pane_graphics_files.rs @@ -9,13 +9,32 @@ 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(crate) struct FileStore { +pub struct FileStore { base: PathBuf, generation: OnceLock<Arc<Generation>>, next_fingerprint: AtomicU64, @@ -30,7 +49,7 @@ struct Generation { /// Keep this owned snapshot alive until the terminal has consumed its path. #[derive(Debug)] -pub(crate) struct OwnedExport { +pub struct OwnedExport { lease: Lease, _file: ExportFile, _reservation: Option<Reservation>, @@ -106,7 +125,7 @@ impl FileStore { } } - pub(crate) fn native_sources() -> Self { + pub fn native_sources() -> Self { let mut store = Self::new(native_base()); store.native_budget = Some(Arc::new(Mutex::new(NativeBudget::default()))); store @@ -114,7 +133,7 @@ impl FileStore { /// Kernel-only bounded snapshot; any unsupported clone returns an error. /// The caller keeps the borrowed descriptor alive throughout this call. - pub(crate) fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result<OwnedExport> { + pub fn snapshot(&self, source_fd: i64, expected_len: usize) -> io::Result<OwnedExport> { if expected_len == 0 || expected_len > MAX_EXPORT_BYTES || !expected_len.is_multiple_of(4) { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -133,7 +152,7 @@ impl FileStore { path, _generation: generation, }; - crate::platform::clone_native_image_source(source_fd, &destination, expected_len)?; + clone_native_image_source(source_fd, &destination, expected_len)?; drop(destination); let lease = self.lease(&file.path, expected_len)?; Ok(OwnedExport { @@ -143,8 +162,9 @@ impl FileStore { }) } - #[cfg(all(test, unix))] - pub(crate) fn source_directory(&self) -> io::Result<PathBuf> { + // 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<PathBuf> { Ok(self.generation()?.source.clone()) } @@ -168,7 +188,7 @@ impl FileStore { /// Snapshot decoded bytes into a private, uniquely named file. Aggregate /// outstanding-export limits are the caller's responsibility. - pub(crate) fn export(&self, data: &[u8]) -> io::Result<OwnedExport> { + pub fn export(&self, data: &[u8]) -> io::Result<OwnedExport> { if data.len() > MAX_EXPORT_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -230,7 +250,7 @@ impl PartialEq for OwnedExport { impl Eq for OwnedExport {} impl OwnedExport { - pub(crate) fn copy_rgba(&self) -> io::Result<Vec<u8>> { + pub fn copy_rgba(&self) -> io::Result<Vec<u8>> { self.lease.copy_rgba() } @@ -238,15 +258,16 @@ impl OwnedExport { self.lease.read_into(data) } - pub(crate) fn path(&self) -> &Path { + pub fn path(&self) -> &Path { self.lease.path() } - pub(crate) fn len(&self) -> usize { + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { self.lease.len() } - pub(crate) fn fingerprint(&self) -> u64 { + pub fn fingerprint(&self) -> u64 { self.lease.fingerprint() } } @@ -255,7 +276,7 @@ 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!(path = %self.path.display(), err = %err, "failed to remove decoded graphics export"); + tracing::warn!(target: LOG_TARGET, path = %self.path.display(), err = %err, "failed to remove decoded graphics export"); } } } @@ -306,14 +327,14 @@ 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!(path = %self.root.display(), err = %err, "failed to remove pane graphics directory"); + tracing::warn!(target: LOG_TARGET, path = %self.root.display(), err = %err, "failed to remove pane graphics directory"); } } } } #[cfg(unix)] -pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> { +pub fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> { validate_source_under(path, expected_len, runtime_base()) } @@ -343,7 +364,7 @@ fn validate_source_under(path: &Path, expected_len: usize, base: PathBuf) -> io: /// Unlike the API validator, accepts only the dedicated native hierarchy. #[cfg(unix)] -pub(crate) fn validate_native_source(path: &Path, expected_len: usize) -> io::Result<()> { +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()); } @@ -463,7 +484,7 @@ fn remove_stale_generations(base: &Path) { continue; } if let Err(err) = fs::remove_dir_all(entry.path()) { - tracing::warn!(path = %entry.path().display(), err = %err, "failed to remove stale pane graphics directory"); + tracing::warn!(target: LOG_TARGET, path = %entry.path().display(), err = %err, "failed to remove stale pane graphics directory"); } } } diff --git a/src/ghostty/png_forward_tests.rs b/crates/ghostty-vt/src/png_forward_tests.rs similarity index 100% rename from src/ghostty/png_forward_tests.rs rename to crates/ghostty-vt/src/png_forward_tests.rs diff --git a/nix/package.nix b/nix/package.nix index 2a506c3a..06ea6055 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/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/src/app/actions.rs b/src/app/actions.rs index 26bdf0e4..6a6e99d2 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2364,7 +2364,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, @@ -2392,7 +2392,7 @@ mod tests { .unwrap(); assert_eq!( regions, - vec![crate::api::schema::PaneLinkRegion { + vec![crate::ghostty::LinkRegion { row: 0, start_col: 0, end_col: 3 diff --git a/src/main.rs b/src/main.rs index d5275529..45a64095 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ mod config; mod copy_mode; mod detect; mod events; -mod ghostty; +use ghostty_vt as ghostty; mod handoff_runtime; mod input; mod integration; @@ -34,7 +34,7 @@ mod logging; mod metadata_tokens; mod noninteractive_process; mod pane; -mod pane_graphics_files; +use ghostty_vt::pane_graphics_files; mod persist; mod platform; mod plugin_command; diff --git a/src/pane.rs b/src/pane.rs index 7d42e9c0..35534135 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -65,7 +65,7 @@ pub(crate) struct TerminalDirtyPatchSnapshot { const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1); const TERMINAL_COMPRESSION_IDLE: std::time::Duration = std::time::Duration::from_millis(250); const TERMINAL_COMPRESSION_STEP: std::time::Duration = std::time::Duration::from_millis(1); -pub(crate) const PANE_TERM: &str = "xterm-256color"; +pub(crate) const PANE_TERM: &str = crate::ghostty::TERM; const PANE_COLORTERM: &str = "truecolor"; fn terminal_compression_permits() -> Arc<tokio::sync::Semaphore> { diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d0b9d3ca..5eaddb21 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2289,6 +2289,13 @@ impl GhosttyPaneTerminal { .ok() }) .unwrap_or_default() + .into_iter() + .map(|region| crate::api::schema::PaneLinkRegion { + row: region.row, + start_col: region.start_col, + end_col: region.end_col, + }) + .collect() } pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option<crate::ghostty::LinkTarget> { diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9be40488..c73584e5 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,6 +1,3 @@ -mod native_image_sources; -pub(crate) use native_image_sources::clone_native_image_source; - use std::{ collections::{HashSet, VecDeque}, io::{Read, Write}, diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 65717228..927cd32b 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -727,16 +727,3 @@ pub(crate) fn shared_ssh_control_path( "interactive SSH recovery requires Unix OpenSSH multiplexing", )) } - -/// Kernel CoW snapshots are deliberately unsupported outside Linux. -#[cfg(not(target_os = "linux"))] -pub(crate) fn clone_native_image_source( - _source_fd: i64, - _destination: &std::fs::File, - _expected_len: usize, -) -> std::io::Result<()> { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "native source cloning requires Linux", - )) -} diff --git a/tests/support/terminal_screen.rs b/tests/support/terminal_screen.rs index 45d7731b..b9d51759 100644 --- a/tests/support/terminal_screen.rs +++ b/tests/support/terminal_screen.rs @@ -1,14 +1,7 @@ use std::{mem::size_of, ptr}; // Reuse the generated C API; this test observer needs only terminal parsing and formatting. -#[allow( - dead_code, - non_camel_case_types, - non_snake_case, - non_upper_case_globals -)] -#[path = "../../src/ghostty/bindings.rs"] -mod ffi; +use ghostty_vt::ffi; struct Screen { terminal: ffi::GhosttyTerminal, From 316d176768e99bd80b81696c4e1f310f0643e744 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson <david@hey.com> Date: Sun, 27 Sep 2026 19:06:33 +0200 Subject: [PATCH 74/99] feat: default machine add label to the ssh host (#4678) * feat: default machine add label to the ssh host `herdr machine add <ssh-target>` no longer requires `--label`. Without one, the machine is named after the SSH host with any `user@` prefix removed, so `herdr machine add dev@workbox` shows up as `workbox`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: make default machine labels unique per session and handle ssh urls --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com> --- .../src/content/docs/cli-reference.mdx | 1 + .../src/content/docs/connecting-machines.mdx | 8 +- src/cli/machine.rs | 97 +++++++++++++++++-- src/cli/spec/machine.rs | 6 +- src/client/endpoint/catalog.rs | 2 +- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 52c39f40..f9aff8fe 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -51,6 +51,7 @@ with the installed binary. Use `--json` for the full JSON Schema document, or ```bash herdr machine list +herdr machine add workbox herdr machine add workbox --label "Build machine" herdr machine rename <profile-id> --label "New name" herdr machine disable <profile-id> diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 9be796f3..2ce18b06 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -32,17 +32,19 @@ Multi-machine connections support Linux, macOS, and Windows clients connecting t Run setup in an interactive terminal so Herdr can ask before installing or replacing anything: ```bash -herdr machine add workbox --label "Build machine" +herdr machine add workbox ``` -This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host. +The sidebar shows the machine as `workbox`, the SSH host without any `user@` prefix. Add `--label "Build machine"` to choose a different name. This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host. To use a named session instead, add the optional `--remote-session` flag: ```bash -herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add workbox --remote-session agents ``` +Without `--label`, this machine shows as `workbox/agents`. If the default name is already taken, `machine add` asks you to pass `--label`. + Herdr checks both the installed binary and the running server. It starts the requested background server before saving the profile. Compatible client and server versions do not have to match. Missing or incompatible installations go through an approval-based setup. When the running server needs replacement, setup asks before stopping it and its pane processes, then starts the compatible server. The default answer is No. If installation and replacement are both needed, one confirmation covers them. `machine add` does not use experimental live handoff. Cancelling or failing setup leaves the profile unsaved. Run `herdr` to open the UI. If a local client is already open, added and enabled machines normally appear within a second and connect in the background without changing your selection. An in-progress machine switch finishes before profile changes are applied. The remote server keeps running after setup exits. diff --git a/src/cli/machine.rs b/src/cli/machine.rs index 9328347e..52828f75 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -1,12 +1,12 @@ 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 status [<label-or-id>] [--json] herdr machine reconnect <label-or-id> - herdr machine add <ssh-target> --label <label> [--remote-session <name>] + herdr machine add <ssh-target> [--label <label>] [--remote-session <name>] herdr machine rename <profile-id> --label <label> herdr machine remove <profile-id> herdr machine enable <profile-id> @@ -205,7 +205,7 @@ fn reconnect(args: &[String]) -> std::io::Result<i32> { #[derive(Debug, PartialEq, Eq)] struct AddArgs { target: String, - label: String, + label: Option<String>, session: String, } @@ -246,9 +246,9 @@ fn parse_add_args(args: &[String]) -> Result<AddArgs, String> { } } let target = target.ok_or_else(|| { - "usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]".to_owned() + "usage: herdr machine add <ssh-target> [--label <label>] [--remote-session <name>]" + .to_owned() })?; - let label = label.ok_or_else(|| "--label is required".to_owned())?; let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); Ok(AddArgs { target, @@ -257,6 +257,41 @@ fn parse_add_args(args: &[String]) -> Result<AddArgs, String> { }) } +/// Names the machine after the SSH host, plus the session when it is not the default. +fn default_label(target: &str, session: &str) -> String { + let url = target + .strip_prefix("ssh://") + .map(|authority| authority.trim_end_matches('/')); + let authority = url.unwrap_or(target); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let host = match (url, host.strip_prefix('[')) { + (Some(_), Some(bracketed)) => bracketed.split_once(']').map_or(host, |(ip, _)| ip), + (Some(_), None) => host.split_once(':').map_or(host, |(host, _)| host), + (None, _) => host, + }; + if session == crate::session::DEFAULT_SESSION_NAME { + host.to_owned() + } else { + format!("{host}/{session}") + } +} + +fn check_default_label(catalog: &EndpointCatalog, label: &str) -> Result<(), String> { + if label.len() > MAX_LABEL_BYTES { + return Err(format!( + "default machine name '{label}' is longer than {MAX_LABEL_BYTES} bytes; pass --label to choose a name" + )); + } + if catalog.ssh.iter().any(|profile| profile.label == label) { + return Err(format!( + "a machine named '{label}' already exists; pass --label to choose another name" + )); + } + Ok(()) +} + fn add(args: &[String]) -> std::io::Result<i32> { let AddArgs { target, @@ -269,7 +304,15 @@ fn add(args: &[String]) -> std::io::Result<i32> { return Ok(2); } }; + let label_is_default = label.is_none(); + let label = label.unwrap_or_else(|| default_label(&target, &session)); let mut catalog = load_catalog()?; + if label_is_default { + if let Err(error) = check_default_label(&catalog, &label) { + eprintln!("error: {error}"); + return Ok(2); + } + } match catalog.add_ssh(label.clone(), &target, session.clone()) { Ok(_) => {} Err(error) => { @@ -291,6 +334,12 @@ fn add(args: &[String]) -> std::io::Result<i32> { "remote prepared, but machine was not saved: {error}" )) })?; + if label_is_default { + if let Err(error) = check_default_label(&catalog, &label) { + eprintln!("error: {error}; machine was not saved"); + return Ok(2); + } + } let id = match catalog.add_ssh(label, &target, &session) { Ok(id) => id, Err(error) => { @@ -457,7 +506,7 @@ mod tests { parse_add_args(&args).unwrap(), AddArgs { target: "workstation.coder".into(), - label: "coder".into(), + label: Some("coder".into()), session: session.into(), }, "{args:?}" @@ -465,12 +514,46 @@ mod tests { } } + #[test] + fn add_parser_leaves_label_unset_without_flag() { + let parsed = parse_add_args(&["workstation.coder".to_owned()]).unwrap(); + assert_eq!(parsed.label, None); + assert_eq!(parsed.session, "default"); + } + + #[test] + fn default_label_uses_ssh_host_and_non_default_session() { + for (target, session, label) in [ + ("workbox", "default", "workbox"), + ("dev@workbox", "default", "workbox"), + ("workbox", "agents", "workbox/agents"), + ("ssh://workbox", "default", "workbox"), + ("ssh://dev@workbox:2222", "default", "workbox"), + ("ssh://dev@[::1]:2222", "agents", "::1/agents"), + ("ssh://dev@workbox/", "default", "workbox"), + ("ssh://dev@workbox:2222/", "agents", "workbox/agents"), + ] { + assert_eq!(default_label(target, session), label, "{target} {session}"); + } + } + + #[test] + fn default_label_must_be_unique_and_fit() { + let mut catalog = EndpointCatalog::default(); + catalog.add_ssh("workbox", "workbox", "default").unwrap(); + + assert!(check_default_label(&catalog, "workbox/agents").is_ok()); + let duplicate = check_default_label(&catalog, "workbox").unwrap_err(); + assert!(duplicate.contains("--label"), "{duplicate}"); + let long = check_default_label(&catalog, &"h".repeat(MAX_LABEL_BYTES + 1)).unwrap_err(); + assert!(long.contains("--label"), "{long}"); + } + #[test] fn add_parser_rejects_incomplete_duplicate_and_extra_arguments() { for args in [ vec![], vec!["--label", "coder"], - vec!["workstation.coder"], vec!["workstation.coder", "--label"], vec!["workstation.coder", "--label", "coder", "--remote-session"], vec!["--label", "coder", "--label", "other", "workstation.coder"], diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs index 82bc4c5d..19a34088 100644 --- a/src/cli/spec/machine.rs +++ b/src/cli/spec/machine.rs @@ -30,9 +30,9 @@ pub(super) fn command() -> Command { .required(true), ) .arg( - option("label", "LABEL") - .required(true) - .help("Set the machine label shown in the sidebar"), + option("label", "LABEL").help( + "Set the machine label shown in the sidebar (defaults to the SSH host, or host/session)", + ), ) .arg( option("remote-session", "NAME") diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs index e65d1e9e..b1812ce7 100644 --- a/src/client/endpoint/catalog.rs +++ b/src/client/endpoint/catalog.rs @@ -11,7 +11,7 @@ const CATALOG_VERSION: u32 = 1; const SELECTION_VERSION: u32 = 1; const MAX_CATALOG_BYTES: u64 = 64 * 1024; const MAX_PROFILES: usize = 64; -const MAX_LABEL_BYTES: usize = 128; +pub(crate) const MAX_LABEL_BYTES: usize = 128; const MAX_TARGET_BYTES: usize = 1024; static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1); From 0d1a39dcee6a0f244a036c55935970f40cfa76b8 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sun, 27 Sep 2026 20:29:49 +0300 Subject: [PATCH 75/99] feat: accept mouse events on terminal session control (#4685) --- .../src/content/docs/cli-reference.mdx | 13 +++- .../src/content/docs/ja/cli-reference.mdx | 2 +- .../content/docs/ja/persistence-remote.mdx | 2 +- .../src/content/docs/persistence-remote.mdx | 5 +- .../src/content/docs/zh-cn/cli-reference.mdx | 2 +- .../content/docs/zh-cn/persistence-remote.mdx | 2 +- src/client/terminal_sessions.rs | 60 +++++++++++++++++- src/client/tests/mod.rs | 61 +++++++++++++++++++ src/server/headless/tests/mod.rs | 46 ++++++++++++++ 9 files changed, 183 insertions(+), 10 deletions(-) diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index f9aff8fe..05a539d6 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -377,8 +377,17 @@ Detach from direct attach with `ctrl+b q`. Send literal `ctrl+b` with `ctrl+b ct terminal, or agent target. It prints the same newline-delimited `terminal.frame` and `terminal.closed` records as observe mode. It reads newline-delimited JSON commands on stdin: `terminal.input`, `terminal.resize`, -`terminal.scroll`, and `terminal.release`. One controller can own a terminal at -a time; use `--takeover` to replace it. +`terminal.scroll`, `terminal.mouse`, and `terminal.release`. One controller can +own a terminal at a time; use `--takeover` to replace it. +`terminal.mouse` sends one mouse event at a zero-based cell of the controller's +viewport, for example +`{"type":"terminal.mouse","action":"down","button":"left","column":12,"row":5}`. +`action` is `down`, `up`, `drag`, or `move`; `button` is `left` (default), +`right`, or `middle`; `modifiers` is optional and combines Shift = 1, Ctrl = 2, +and Alt = 4 (default 0). A click is `down` followed by `up`. Herdr encodes the +event for the application's mouse mode and drops it when the application has +not enabled mouse reporting, so a plain shell never receives stray input. +`move` reaches only applications that track all mouse motion. `terminal session observe` opens a read-only live terminal stream for a pane, terminal, or agent target. It prints newline-delimited JSON `terminal.frame` records with base64-encoded ANSI bytes, then a `terminal.closed` record when diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 81894daa..b622c245 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -351,7 +351,7 @@ herdr terminal title clear ``` ダイレクトアタッチからは `ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 -`terminal session control` は、ペイン、ターミナル、エージェントを対象に書き込み可能なライブストリームを開きます。observe と同じ改行区切り JSON の `terminal.frame` と `terminal.closed` を出力し、標準入力から `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` を受け取ります。制御クライアントは同時に 1 つだけで、`--takeover` で置き換えられます。 +`terminal session control` は、ペイン、ターミナル、エージェントを対象に書き込み可能なライブストリームを開きます。observe と同じ改行区切り JSON の `terminal.frame` と `terminal.closed` を出力し、標準入力から `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.mouse`、`terminal.release` を受け取ります。制御クライアントは同時に 1 つだけで、`--takeover` で置き換えられます。`terminal.mouse` は 0 始まりのセル位置にマウスイベントを 1 つ送ります(例: `{"type":"terminal.mouse","action":"down","button":"left","column":12,"row":5}`)。`action` は `down`、`up`、`drag`、`move` のいずれかで、`button` は `left`(既定)、`right`、`middle`、`modifiers` は省略可能で Shift = 1、Ctrl = 2、Alt = 4 の組み合わせです(既定 0)。クリックは `down` の後に `up` を送ります。Herdr はアプリのマウスモードに合わせてエンコードし、マウス報告を有効にしていないアプリには送らないため、通常のシェルに余計な入力が届くことはありません。`move` はすべてのマウス移動を追跡するアプリにのみ届きます。 `terminal session observe` は同じ対象の読み取り専用ストリームを開きます。base64 の ANSI バイト列を含む `terminal.frame` と、サーバー終了時の `terminal.closed` を改行区切り JSON で出力します。複数の観測クライアントは入力、リサイズ、スクロール、制御権を取得せずに同じターミナルを監視できます。Linux/macOS ではソケットへの書き込みが 30 秒間進まない観測クライアントを切断します。その場合は最後の `terminal.closed` が届かないことがありますが、ペインや他のクライアントは動き続けます。出力のない静かなペインはこのタイムアウトの対象ではありません。 diff --git a/docs/next/website/src/content/docs/ja/persistence-remote.mdx b/docs/next/website/src/content/docs/ja/persistence-remote.mdx index 94bffb8b..ae695ab3 100644 --- a/docs/next/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/ja/persistence-remote.mdx @@ -148,4 +148,4 @@ base64 の ANSI データを含む `terminal.frame` を改行区切り JSON で herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 ``` -同じフレームを出力し、標準入力から改行区切り JSON コマンドを読み取ります。`terminal.input` はテキストまたは base64 データ、`terminal.resize` は表示サイズ、`terminal.scroll` はビューポートのスクロール、`terminal.release` はコントローラーの終了を扱います。入力とリサイズを所有できるコントローラーは 1 つだけです。 +同じフレームを出力し、標準入力から改行区切り JSON コマンドを読み取ります。`terminal.input` はテキストまたは base64 データ、`terminal.resize` は表示サイズ、`terminal.scroll` はビューポートのスクロール、`terminal.mouse` はマウス報告を有効にしたアプリへのセル位置のマウスイベント、`terminal.release` はコントローラーの終了を扱います。入力とリサイズを所有できるコントローラーは 1 つだけです。 diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index 4f42cef5..0ffa1406 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -155,5 +155,6 @@ herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 Control mode prints the same newline-delimited frame records and reads newline-delimited JSON commands on stdin. `terminal.input` sends text or base64 bytes, `terminal.resize` changes the controller viewport, -`terminal.scroll` scrolls the attached viewport, and `terminal.release` closes -the controller. Only one controller owns input and resize at a time. +`terminal.scroll` scrolls the attached viewport, `terminal.mouse` sends a mouse +event at a cell to applications that enabled mouse reporting, and +`terminal.release` closes the controller. Only one controller owns input and resize at a time. diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 080abb48..987e0c4a 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -351,7 +351,7 @@ herdr terminal title clear ``` 从直接附加中用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 -`terminal session control` 为窗格、终端或智能体打开可写的实时终端流,输出与 observe 相同的逐行 JSON `terminal.frame` 和 `terminal.closed`,并从标准输入读取 `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` 命令。同一终端同时只能有一个控制方,可用 `--takeover` 接管。 +`terminal session control` 为窗格、终端或智能体打开可写的实时终端流,输出与 observe 相同的逐行 JSON `terminal.frame` 和 `terminal.closed`,并从标准输入读取 `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.mouse`、`terminal.release` 命令。同一终端同时只能有一个控制方,可用 `--takeover` 接管。`terminal.mouse` 在从 0 开始计数的单元格位置发送一个鼠标事件,例如 `{"type":"terminal.mouse","action":"down","button":"left","column":12,"row":5}`。`action` 为 `down`、`up`、`drag` 或 `move`;`button` 为 `left`(默认)、`right` 或 `middle`;`modifiers` 可省略,由 Shift = 1、Ctrl = 2、Alt = 4 组合(默认 0)。一次点击就是先 `down` 再 `up`。Herdr 会按应用的鼠标模式编码事件;应用未开启鼠标报告时直接丢弃,因此普通 shell 不会收到多余输入。`move` 只会发给追踪全部鼠标移动的应用。 `terminal session observe` 为相同目标打开只读实时流,输出包含 base64 ANSI 字节的逐行 JSON `terminal.frame`,服务器关闭流时输出 `terminal.closed`。多个观察方可以同时查看终端,不获取输入、调整尺寸、滚动或接管权限。Linux/macOS 上,若套接字写入连续 30 秒没有进展,观察方会被断开;此时可能没有最后的 `terminal.closed` 记录,但窗格和其他客户端继续运行。安静、没有输出的窗格不会触发此超时。 diff --git a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx index ddd0b0fb..6b0be4d8 100644 --- a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -148,4 +148,4 @@ herdr terminal session observe w1:p1 --cols 120 --rows 40 herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 ``` -控制模式输出相同的帧,并从标准输入读取逐行 JSON 命令。`terminal.input` 发送文本或 base64 数据,`terminal.resize` 更改视口尺寸,`terminal.scroll` 滚动视口,`terminal.release` 关闭控制器。同一时间只有一个控制器能拥有输入和调整尺寸的权限。 +控制模式输出相同的帧,并从标准输入读取逐行 JSON 命令。`terminal.input` 发送文本或 base64 数据,`terminal.resize` 更改视口尺寸,`terminal.scroll` 滚动视口,`terminal.mouse` 向已开启鼠标报告的应用发送单元格位置的鼠标事件,`terminal.release` 关闭控制器。同一时间只有一个控制器能拥有输入和调整尺寸的权限。 diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs index 6d3160c8..aeecbf47 100644 --- a/src/client/terminal_sessions.rs +++ b/src/client/terminal_sessions.rs @@ -7,8 +7,8 @@ use tracing::info; use crate::ipc::LocalStream; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientMessage, RenderEncoding, ServerMessage, - MAX_GRAPHICS_FRAME_SIZE, + self, AttachScrollDirection, AttachScrollSource, ClientMessage, ClientMouseButton, + ClientMouseKind, ClientMousePosition, RenderEncoding, ServerMessage, MAX_GRAPHICS_FRAME_SIZE, }; use crate::server::socket_paths::client_socket_path; @@ -187,10 +187,38 @@ enum TerminalControlCommand { #[serde(default)] modifiers: u8, }, + #[serde(rename = "terminal.mouse")] + Mouse { + action: TerminalControlMouseAction, + #[serde(default)] + button: TerminalControlMouseButton, + column: u16, + row: u16, + #[serde(default)] + modifiers: u8, + }, #[serde(rename = "terminal.release")] Release {}, } +#[derive(Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlMouseAction { + Down, + Up, + Drag, + Move, +} + +#[derive(Clone, Copy, Default, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum TerminalControlMouseButton { + #[default] + Left, + Right, + Middle, +} + #[derive(Clone, Copy, serde::Deserialize)] #[serde(rename_all = "snake_case")] enum TerminalControlScrollDirection { @@ -273,6 +301,34 @@ pub(super) fn terminal_control_command_from_json(raw: &str) -> Result<ClientMess modifiers, }) } + TerminalControlCommand::Mouse { + action, + button, + column, + row, + modifiers, + } => { + let button = match button { + TerminalControlMouseButton::Left => ClientMouseButton::Left, + TerminalControlMouseButton::Right => ClientMouseButton::Right, + TerminalControlMouseButton::Middle => ClientMouseButton::Middle, + }; + let kind = match action { + TerminalControlMouseAction::Down => ClientMouseKind::Down(button), + TerminalControlMouseAction::Up => ClientMouseKind::Up(button), + TerminalControlMouseAction::Drag => ClientMouseKind::Drag(button), + TerminalControlMouseAction::Move => ClientMouseKind::Moved, + }; + // The server encodes the event for the pane's mouse mode and drops it + // when the application has not enabled mouse reporting. + Ok(ClientMessage::AttachMouse { + kind, + position: ClientMousePosition::Cell { column, row }, + geometry: None, + modifiers, + lines: 1, + }) + } TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), } } diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 8519ea07..2fcac6eb 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -886,6 +886,67 @@ fn terminal_control_resize_command_maps_to_client_resize() { assert!(!pixel_mouse); } +#[test] +fn terminal_control_mouse_command_maps_to_attach_mouse() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.mouse","action":"down","column":12,"row":5}"#, + ) + .unwrap(); + let ClientMessage::AttachMouse { + kind, + position, + geometry, + modifiers, + lines, + } = action + else { + panic!("expected attach mouse command"); + }; + assert_eq!( + kind, + crate::protocol::ClientMouseKind::Down(crate::protocol::ClientMouseButton::Left) + ); + assert_eq!( + position, + crate::protocol::ClientMousePosition::Cell { column: 12, row: 5 } + ); + assert_eq!((geometry, modifiers, lines), (None, 0, 1)); +} + +#[test] +fn terminal_control_mouse_command_maps_every_action_and_button() { + use crate::protocol::{ClientMouseButton as Button, ClientMouseKind as Kind}; + for (json, expected) in [ + (r#""action":"up","button":"right""#, Kind::Up(Button::Right)), + ( + r#""action":"drag","button":"middle""#, + Kind::Drag(Button::Middle), + ), + (r#""action":"move""#, Kind::Moved), + ] { + let raw = format!(r#"{{"type":"terminal.mouse",{json},"column":0,"row":0,"modifiers":4}}"#); + let ClientMessage::AttachMouse { + kind, modifiers, .. + } = terminal_control_command_from_json(&raw).unwrap() + else { + panic!("expected attach mouse command"); + }; + assert_eq!((kind, modifiers), (expected, 4), "{raw}"); + } +} + +#[test] +fn terminal_control_mouse_command_rejects_unknown_actions_and_missing_cells() { + assert!(terminal_control_command_from_json( + r#"{"type":"terminal.mouse","action":"click","column":1,"row":1}"# + ) + .is_err()); + assert!(terminal_control_command_from_json( + r#"{"type":"terminal.mouse","action":"down","column":1}"# + ) + .is_err()); +} + #[test] fn terminal_control_scroll_command_maps_to_attach_scroll() { let action = terminal_control_command_from_json( diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 34258dd3..4def3fa4 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -6309,6 +6309,52 @@ fn direct_terminal_mouse_uses_runtime_protocol_encoding() { }); } +#[test] +fn direct_terminal_mouse_sends_nothing_without_mouse_reporting() { + with_terminal_session_test_server(|server, runtime_terminal_id, terminal_id, _pane_id| { + // A plain shell never enabled mouse reporting, so a controller's click + // must not reach it as stray escape bytes. + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 4, + ); + server + .app + .terminal_runtimes + .insert(runtime_terminal_id, runtime); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::TerminalAnsi, + None, + ), + ); + + for kind in [ + protocol::ClientMouseKind::Down(protocol::ClientMouseButton::Left), + protocol::ClientMouseKind::Drag(protocol::ClientMouseButton::Left), + protocol::ClientMouseKind::Up(protocol::ClientMouseButton::Left), + protocol::ClientMouseKind::Moved, + ] { + server.handle_server_event(ServerEvent::ClientAttachMouse { + client_id: 1, + kind, + position: protocol::ClientMousePosition::Cell { column: 10, row: 5 }, + geometry: None, + modifiers: 0, + lines: 1, + }); + } + assert!(input_rx.try_recv().is_err()); + }); +} + #[test] fn direct_terminal_pixel_mouse_uses_runtime_tracking_and_coordinates() { with_terminal_session_test_server(|server, runtime_terminal_id, terminal_id, _pane_id| { From 2552d101afe46b8622a7695f38f6cd1766dd1afc Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sun, 27 Sep 2026 20:57:00 +0300 Subject: [PATCH 76/99] feat: let agents report their own resume command (#4687) * feat: let agents report their own resume command * fix: keep reported resume commands tied to accepted sessions and current owners --- docs/next/api/herdr-api.schema.json | 20 ++ .../src/content/docs/cli-reference.mdx | 8 +- .../website/src/content/docs/integrations.mdx | 16 +- .../src/content/docs/session-state.mdx | 2 +- .../website/src/content/docs/socket-api.mdx | 16 ++ src/agent_resume.rs | 95 ++++++++ src/api/schema/panes.rs | 8 + src/app/actions.rs | 225 +++++++++++++++++- src/app/api.rs | 1 + src/app/api/panes.rs | 132 ++++++++-- src/app/api/plugins/mod.rs | 1 + src/cli/pane.rs | 15 +- src/cli/spec.rs | 10 + src/detect/mod.rs | 48 ++++ src/events.rs | 13 + src/pane.rs | 65 +++++ src/persist/restore.rs | 163 ++++++++++++- src/persist/snapshot.rs | 55 +++++ src/server/headless/tests/mod.rs | 140 +++++++++++ src/terminal/runtime.rs | 4 + src/terminal/state.rs | 188 ++++++++++++++- 21 files changed, 1178 insertions(+), 47 deletions(-) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 3151bff3..e2553f75 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -2909,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, @@ -2952,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, diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 05a539d6..1b6f7019 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -278,7 +278,8 @@ herdr pane report-agent <pane_id> \ [--message TEXT] \ [--seq N] \ [--agent-session-id ID] \ - [--agent-session-path PATH] + [--agent-session-path PATH] \ + [-- RESUME_COMMAND...] herdr pane report-agent-session <pane_id> \ --source ID \ @@ -286,7 +287,8 @@ herdr pane report-agent-session <pane_id> \ [--seq N] \ [--agent-session-id ID] \ [--agent-session-path PATH] \ - [--session-start-source SOURCE] + [--session-start-source SOURCE] \ + [-- RESUME_COMMAND...] herdr pane release-agent <pane_id> \ --source ID \ @@ -296,6 +298,8 @@ herdr pane release-agent <pane_id> \ `report-agent-session` updates native session identity without reporting lifecycle state. `release-agent` ends that source's lifecycle authority when its agent process exits. +Arguments after `--` are the command Herdr runs to resume this session after a server restart. The first argument must be a plain command name on `PATH`, and no argument may contain an apostrophe. + `pane get`, `pane list`, `agent get`, and `agent list` include a read-only `agent_session` object when an official integration has reported a native session reference. If no native session reference is stored, the field is omitted. Those commands include `foreground_cwd` when Herdr can resolve the cwd of the foreground process controlling the pane. The `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 1261b38d..e36dfdf2 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -87,7 +87,21 @@ Report `idle` when the agent is ready for input and `blocked` when it needs a us Report only when `HERDR_ENV=1` and the required variables are present. This keeps the integration a no-op outside Herdr. Keep `--source` stable and unique to the integration. If reports can arrive out of order, include a strictly increasing `--seq`; Herdr ignores stale sequence numbers from the same source. -You can include `--agent-session-id` or `--agent-session-path` with `report-agent`, or use `pane report-agent-session` when session identity changes independently of state. Herdr exposes that reference through its pane and agent APIs. Automatic session restore also requires Herdr to know how to launch that agent and resume the referenced session. +You can include `--agent-session-id` or `--agent-session-path` with `report-agent`, or use `pane report-agent-session` when session identity changes independently of state. Herdr exposes that reference through its pane and agent APIs. + +To have Herdr resume your agent after a Herdr server restart, report the command that resumes the current session after `--`. Include any options the session needs, such as the model or permission mode: + +```bash +"$HERDR_BIN_PATH" pane report-agent-session "$HERDR_PANE_ID" \ + --source custom:my-agent \ + --agent my-agent \ + --agent-session-id "$SESSION_ID" \ + -- my-agent --resume "$SESSION_ID" --model my-model +``` + +Herdr runs that command in the restored pane's directory. The first word must be a plain command name on `PATH`, not a path, and no argument may contain an apostrophe. Report the agent's state with `report-agent` first so your source holds the pane, and report the command again whenever the session changes. Herdr keeps it only while your source holds the pane and drops it on release. `[session] resume_agents_on_restore = false` disables it. + +Herdr cannot identify a custom agent's process, so it treats the pane returning to its idle shell prompt as the agent exiting and clears the agent and its resume command. Still release authority on exit so the pane updates immediately. Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that needs direct IPC can send the equivalent `pane.report_agent`, `pane.report_agent_session`, and `pane.release_agent` requests described in the [Socket API](/docs/socket-api/#agent-state-reporting). diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index b8706cd5..d333dab7 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -81,7 +81,7 @@ Native agent session restore is enabled by default. Disable it with: resume_agents_on_restore = false ``` -Herdr only resumes panes that reported a native session reference through a current official Herdr integration. +Herdr resumes panes that reported a native session reference through a current official Herdr integration, and panes whose agent reported its own resume command. See [Integrate your own agent](/docs/integrations/#integrate-your-own-agent). After a client attaches and provides terminal size and theme context, Herdr resumes eligible restored agent panes across workspaces and tabs without waiting for each pane to be focused. diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 4572b5ab..9e844d7e 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -687,6 +687,22 @@ Session-only official integrations report native session references with `pane.r } ``` +Both methods accept an optional `resume_argv` array with the command that resumes the reporter's current session after a Herdr server restart. Its first element must be a plain command name, not a path, and no element may contain an apostrophe; invalid values fail with `invalid_resume_argv` and the report is not applied. A custom agent must hold the pane through `pane.report_agent` before it can attach a command; otherwise the request fails with `resume_not_accepted`. Herdr keeps the command only while the same `source` and `agent` hold the pane, and it takes precedence over Herdr's built-in resume command for that agent. Older Herdr versions ignore the field. + +```json +{ + "id": "req_3", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "custom:my-agent", + "agent": "my-agent", + "agent_session_id": "...", + "resume_argv": ["my-agent", "--resume", "..."] + } +} +``` + `pane.get`, `pane.list`, `agent.get`, and `agent.list` expose a read-only `agent_session` object when Herdr has a stored native session reference: ```json diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 56554dd3..d6a68e45 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -4,6 +4,8 @@ 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, Serialize, Deserialize)] pub struct AgentSessionRef { @@ -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<String>, +} + +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::<usize>() > 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<String> { + 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/schema/panes.rs b/src/api/schema/panes.rs index dfef31f8..9dbe0221 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -372,6 +372,10 @@ pub struct PaneReportAgentParams { pub agent_session_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session_path: Option<String>, + /// 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<Vec<String>>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -387,6 +391,10 @@ pub struct PaneReportAgentSessionParams { pub agent_session_path: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_start_source: Option<String>, + /// 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<Vec<String>>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/app/actions.rs b/src/app/actions.rs index 6a6e99d2..e1ae2ffc 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1500,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, @@ -1653,7 +1675,14 @@ impl AppState { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); let had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen; - let mutation = update(terminal)?; + 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 @@ -3541,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/api.rs b/src/app/api.rs index 62c8a6b5..20ca9d0f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -462,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()); } } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 119d9298..e4e03e71 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1549,28 +1549,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( @@ -1578,28 +1594,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<u64>, + resume_argv: Option<Vec<String>>, + ) -> 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 {}) } @@ -2214,6 +2302,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::*; 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/cli/pane.rs b/src/cli/pane.rs index 3b900e17..33583ee4 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1156,8 +1156,9 @@ fn parse_pane_wait_output_args(args: &[String]) -> Result<PaneWaitForOutputParam } fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { - const USAGE: &str = "usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"; + const USAGE: &str = "usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [-- <resume-command...>]"; + let (args, resume_argv) = split_resume_argv(args); let args = super::expand_equals_args( args, &[ @@ -1282,12 +1283,21 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { seq, agent_session_id, agent_session_path, + resume_argv, })) } +fn split_resume_argv(args: &[String]) -> (&[String], Option<Vec<String>>) { + match args.iter().position(|arg| arg == "--") { + Some(separator) => (&args[..separator], Some(args[separator + 1..].to_vec())), + None => (args, None), + } +} + fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { - const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE]"; + const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE] [-- <resume-command...>]"; + let (args, resume_argv) = split_resume_argv(args); let args = super::expand_equals_args( args, &[ @@ -1398,6 +1408,7 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { agent_session_id, agent_session_path, session_start_source, + resume_argv, }, )) } diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 9465dcc3..1cfc2e7f 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -646,6 +646,7 @@ fn report_agent_command() -> Command { .arg(option("seq", "N")) .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) + .arg(resume_argv_arg()) } fn report_agent_session_command() -> Command { @@ -658,6 +659,15 @@ fn report_agent_session_command() -> Command { .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) .arg(option("session-start-source", "SOURCE")) + .arg(resume_argv_arg()) +} + +fn resume_argv_arg() -> Arg { + Arg::new("resume_argv") + .value_name("RESUME_ARG") + .num_args(0..) + .last(true) + .help("Command that resumes this session after a Herdr restart; starts with a plain command name") } fn release_agent_command() -> Command { diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 408ee669..7d7759d2 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -366,6 +366,11 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> { crate::platform::foreground_process_group_id(child_pid) } +/// True when the pane's own shell is at its prompt with nothing running in it. +pub fn pane_shell_is_idle(child_pid: u32) -> bool { + crate::platform::available_pane_shell(child_pid).is_some() +} + fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String { let effective = process.argv0.as_deref().unwrap_or(&process.name); let lower_effective = effective.to_lowercase(); @@ -1926,6 +1931,49 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn pane_shell_is_idle_tracks_foreground_command() { + use portable_pty::CommandBuilder; + use std::io::Write; + + fn wait_for(expected: bool, pid: u32) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + if pane_shell_is_idle(pid) == expected { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + false + } + + let pair = open_test_pty(); + let mut cmd = CommandBuilder::new("sh"); + cmd.arg("-i"); + cmd.env("ENV", "/dev/null"); + let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn"); + let pid = child.process_id().expect("no pid"); + let mut reader = pair.master.try_clone_reader().expect("no reader"); + std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + while matches!(std::io::Read::read(&mut reader, &mut buf), Ok(n) if n > 0) {} + }); + let mut writer = pair.master.take_writer().expect("no writer"); + + assert!(wait_for(true, pid), "interactive shell should start idle"); + writer.write_all(b"sleep 999\n").unwrap(); + assert!(wait_for(false, pid), "shell running a command is not idle"); + writer.write_all(&[3]).unwrap(); + assert!( + wait_for(true, pid), + "shell should be idle after the command ends" + ); + + child.kill().ok(); + child.wait().ok(); + } + #[cfg(target_os = "linux")] #[test] fn proc_stat_parsing_handles_spaces_in_comm() { diff --git a/src/events.rs b/src/events.rs index 0e61f5f8..c87a50a3 100644 --- a/src/events.rs +++ b/src/events.rs @@ -120,6 +120,19 @@ pub enum AppEvent { session_ref: Option<crate::agent_resume::AgentSessionRef>, session_start_source: Option<String>, }, + /// A reporter supplied the command that resumes its own session. + AgentResumeReported { + pane_id: PaneId, + source: String, + agent_label: String, + seq: Option<u64>, + argv: Vec<String>, + }, + /// A pane held by a self-reported agent is back at its idle shell. + ReportedAgentShellReturned { + pane_id: PaneId, + observed_at: std::time::Instant, + }, /// Display-only agent metadata was reported for a pane. HookMetadataReported { pane_id: PaneId, diff --git a/src/pane.rs b/src/pane.rs index 35534135..e5695675 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -363,6 +363,35 @@ async fn apply_agent_detection_publish_update( } const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6; +const SELF_REPORTED_AGENT_SHELL_RECHECK: std::time::Duration = std::time::Duration::from_secs(1); + +/// Herdr cannot identify a self-reported agent's process, so the pane's shell +/// returning to an idle prompt is the only sign that the agent exited. +async fn report_self_reported_agent_shell_return( + active: &AtomicBool, + pid: u32, + now: std::time::Instant, + last_check: &mut Option<std::time::Instant>, + state_events: &mpsc::Sender<AppEvent>, + pane_id: PaneId, +) { + if pid == 0 || !active.load(Ordering::Acquire) { + *last_check = None; + return; + } + if last_check.is_some_and(|last| now.duration_since(last) < SELF_REPORTED_AGENT_SHELL_RECHECK) { + return; + } + *last_check = Some(now); + if crate::detect::pane_shell_is_idle(pid) { + let _ = state_events + .send(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: now, + }) + .await; + } +} const PROCESS_RECHECK_IDENTIFIED: std::time::Duration = std::time::Duration::from_secs(5); const PROCESS_RECHECK_MISSING_FOREGROUND_GROUP: std::time::Duration = std::time::Duration::from_secs(30); @@ -766,6 +795,7 @@ fn spawn_basic_detection_task( terminal: Arc<PaneTerminal>, detection_content_seq: Arc<AtomicU64>, full_lifecycle_authority_active: Arc<AtomicBool>, + self_reported_agent_active: Arc<AtomicBool>, state_events: mpsc::Sender<AppEvent>, ) -> ( tokio::task::AbortHandle, @@ -797,6 +827,7 @@ fn spawn_basic_detection_task( let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut last_codex_prompt_ready = false; + let mut last_self_reported_shell_check = None; loop { let sleep_duration = if pending_idle.active() { @@ -845,6 +876,15 @@ fn spawn_basic_detection_task( let mut agent = agent_presence.current_agent(); let lifecycle_authority_active = full_lifecycle_authority_active.load(Ordering::Acquire); + report_self_reported_agent_shell_return( + &self_reported_agent_active, + pid, + now, + &mut last_self_reported_shell_check, + &state_events, + pane_id, + ) + .await; let foreground_pgid = (pid > 0) .then(|| crate::detect::foreground_process_group_id(pid)) .flatten(); @@ -1328,6 +1368,7 @@ pub struct PaneRuntime { content_write_lock: Arc<Mutex<()>>, detection_content_seq: Arc<AtomicU64>, full_lifecycle_authority_active: Arc<AtomicBool>, + self_reported_agent_active: Arc<AtomicBool>, detect_reset_notify: Arc<Notify>, pending_release: Arc<Mutex<Option<PendingAgentRelease>>>, preserve_processes_on_drop: bool, @@ -2461,12 +2502,14 @@ impl PaneRuntime { }; let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false)); + let self_reported_agent_active = Arc::new(AtomicBool::new(false)); let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task( pane_id, child_pid.clone(), terminal.clone(), detection_content_seq.clone(), full_lifecycle_authority_active.clone(), + self_reported_agent_active.clone(), events, ); @@ -2485,6 +2528,7 @@ impl PaneRuntime { content_write_lock, detection_content_seq, full_lifecycle_authority_active, + self_reported_agent_active, detect_reset_notify, pending_release, preserve_processes_on_drop: true, @@ -2544,6 +2588,7 @@ impl PaneRuntime { let content_seq = Arc::new(AtomicU64::new(0)); let detection_content_seq = Arc::new(AtomicU64::new(0)); let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false)); + let self_reported_agent_active = Arc::new(AtomicBool::new(false)); { let child_pid = child_pid.clone(); let child_wait_completed = child_wait_completed.clone(); @@ -2667,6 +2712,7 @@ impl PaneRuntime { let state_events = events.clone(); let detection_content_seq = detection_content_seq.clone(); let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone(); + let self_reported_agent_active_for_task = self_reported_agent_active.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let detect_reset_notify = Arc::new(Notify::new()); @@ -2698,6 +2744,7 @@ impl PaneRuntime { let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut last_codex_prompt_ready = false; + let mut last_self_reported_shell_check = None; tokio::time::sleep(Duration::from_millis(50)).await; @@ -2755,6 +2802,15 @@ impl PaneRuntime { let mut agent = agent_presence.current_agent(); let lifecycle_authority_active = full_lifecycle_authority_active_for_task.load(Ordering::Acquire); + report_self_reported_agent_shell_return( + &self_reported_agent_active_for_task, + pid, + now, + &mut last_self_reported_shell_check, + &state_events, + pane_id, + ) + .await; let process_probe_input = ProcessProbeInput { current_agent: agent, suppressed_agent, @@ -3075,6 +3131,7 @@ impl PaneRuntime { content_write_lock, detection_content_seq, full_lifecycle_authority_active, + self_reported_agent_active, detect_reset_notify, pending_release, preserve_processes_on_drop: false, @@ -3102,6 +3159,11 @@ impl PaneRuntime { self.detect_reset_notify.clone() } + pub fn set_self_reported_agent_active(&self, active: bool) { + self.self_reported_agent_active + .store(active, Ordering::Release); + } + pub fn set_full_lifecycle_authority_active(&self, active: bool) { let previous = self .full_lifecycle_authority_active @@ -3789,6 +3851,7 @@ impl PaneRuntime { content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), + self_reported_agent_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, @@ -4940,6 +5003,7 @@ mod tests { )); let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone()); let runtime = PaneRuntime { + self_reported_agent_active: Arc::new(AtomicBool::new(false)), cwd_process_exited: Arc::new(AtomicBool::new(false)), persistence_cwd: Mutex::new(None), pane_id, @@ -4979,6 +5043,7 @@ mod tests { )); let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone()); let runtime = PaneRuntime { + self_reported_agent_active: Arc::new(AtomicBool::new(false)), cwd_process_exited: Arc::new(AtomicBool::new(false)), persistence_cwd: Mutex::new(None), pane_id, diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 265b4874..e79a0f3b 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -15,7 +15,8 @@ use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; use super::snapshot::{ - PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot, WorkspaceHistorySnapshot, + PaneAgentResumeSnapshot, PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot, + WorkspaceHistorySnapshot, }; use super::{ DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot, SessionSnapshot, TabSnapshot, @@ -455,6 +456,9 @@ fn unavailable_restored_terminal( if let Some(session) = restored_terminal_agent_session(pane.agent_session.as_ref(), false) { terminal.set_persisted_agent_session(session); } + if let Some(resume) = saved_reported_resume(pane) { + terminal.restore_reported_resume(reported_resume_from_snapshot(resume)); + } match ( pane.agent_name.as_ref(), pane.managed_agent_kind @@ -530,6 +534,7 @@ fn restore_tab( .and_then(crate::detect::parse_canonical_agent_label); let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone()); let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); + let saved_agent_resume = saved_pane.and_then(saved_reported_resume); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); let startup = { @@ -537,8 +542,17 @@ fn restore_tab( enabled: runtime_context.resume_agents_on_restore, resumed_sessions: resumed_agent_sessions, }; - pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore) + pane_restore_startup( + saved_agent_session, + saved_agent_resume, + &cwd, + saved_history, + &mut agent_restore, + ) }; + let restored_agent_resume = saved_agent_resume + .filter(|_| !startup.duplicate_agent_session) + .map(reported_resume_from_snapshot); let restored_agent_session = restored_terminal_agent_session(saved_agent_session, startup.duplicate_agent_session); let initial_restore_agent = startup @@ -580,6 +594,9 @@ fn restore_tab( if let Some(session) = restored_agent_session { terminal.set_persisted_agent_session(session); } + if let Some(resume) = restored_agent_resume { + terminal.restore_reported_resume(resume); + } match (saved_agent_name, saved_managed_agent) { (Some(agent_name), Some(agent)) => { terminal.restore_managed_agent(agent_name, agent) @@ -677,6 +694,9 @@ fn restore_tab( if let Some(session) = restored_agent_session { terminal.set_persisted_agent_session(session); } + if let Some(resume) = restored_agent_resume { + terminal.restore_reported_resume(resume); + } match (saved_agent_name, saved_managed_agent) { (Some(agent_name), Some(agent)) if was_imported => { terminal.restore_managed_agent(agent_name, agent) @@ -787,6 +807,8 @@ fn restore_tab( fn pane_restore_startup<'a>( session: Option<&PaneAgentSessionSnapshot>, + reported_resume: Option<&PaneAgentResumeSnapshot>, + cwd: &std::path::Path, history: Option<&'a PaneHistorySnapshot>, agent_restore: &mut AgentRestoreState<'_>, ) -> PaneRestoreStartup<'a> { @@ -794,8 +816,13 @@ fn pane_restore_startup<'a>( // resumable agent session and resume is enabled, do not replay saved pane // presentation history into that terminal, even when this pane is a // duplicate suppressed by session de-duplication. - let restore_plan = - session.and_then(|session| restore_plan_for_snapshot(session, agent_restore.enabled)); + let restore_plan = if !agent_restore.enabled { + None + } else if let Some(resume) = reported_resume { + Some(reported_resume_from_snapshot(resume).plan(cwd)) + } else { + session.and_then(|session| restore_plan_for_snapshot(session, true)) + }; let has_native_agent_restore = restore_plan.is_some(); // Reserve before spawning so later panes in the same restore pass cannot // launch the same native agent session. The caller rolls this reservation @@ -830,6 +857,22 @@ fn pane_restore_startup<'a>( } } +fn saved_reported_resume(pane: &super::snapshot::PaneSnapshot) -> Option<&PaneAgentResumeSnapshot> { + pane.agent_resume + .as_ref() + .filter(|resume| crate::agent_resume::validate_resume_argv(&resume.argv).is_ok()) +} + +fn reported_resume_from_snapshot( + resume: &PaneAgentResumeSnapshot, +) -> crate::agent_resume::ReportedAgentResume { + crate::agent_resume::ReportedAgentResume { + source: resume.source.clone(), + agent: resume.agent.clone(), + argv: resume.argv.clone(), + } +} + fn restore_plan_for_snapshot( session: &PaneAgentSessionSnapshot, resume_agents_on_restore: bool, @@ -1124,7 +1167,13 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); assert!(startup.restore_plan.is_some()); assert!(startup.initial_history_ansi.is_none()); @@ -1149,8 +1198,20 @@ mod tests { resumed_sessions: &mut resumed, }; - let first = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); - let duplicate = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let first = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); + let duplicate = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); assert!(first.restore_plan.is_some()); assert!(first.initial_history_ansi.is_none()); @@ -1159,6 +1220,79 @@ mod tests { assert!(duplicate.duplicate_agent_session); } + #[test] + fn pane_restore_startup_prefers_reported_resume_argv() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: test_session_path("pi-session.jsonl"), + }; + let resume = super::super::snapshot::PaneAgentResumeSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + argv: vec![ + "pi".into(), + "--continue".into(), + "--model".into(), + "m".into(), + ], + }; + let history = super::super::snapshot::PaneHistorySnapshot { + ansi: "RESTORED_HISTORY\r\n".into(), + lines: 1, + }; + let mut resumed = HashSet::new(); + let mut agent_restore = AgentRestoreState { + enabled: true, + resumed_sessions: &mut resumed, + }; + + let project_a = std::path::Path::new("/project-a"); + let project_b = std::path::Path::new("/project-b"); + let startup = pane_restore_startup( + Some(&session), + Some(&resume), + project_a, + Some(&history), + &mut agent_restore, + ); + let plan = startup.restore_plan.expect("reported resume plan"); + assert_eq!(plan.agent, "pi"); + assert_eq!(plan.argv, resume.argv); + assert!(startup.initial_history_ansi.is_none()); + + let custom = super::super::snapshot::PaneAgentResumeSnapshot { + source: "prime-agent".into(), + agent: "prime-agent".into(), + argv: vec!["prime-agent".into(), "--continue".into()], + }; + let first = pane_restore_startup(None, Some(&custom), project_a, None, &mut agent_restore); + assert_eq!(first.restore_plan.unwrap().argv, custom.argv); + let other_project = + pane_restore_startup(None, Some(&custom), project_b, None, &mut agent_restore); + assert!(other_project.restore_plan.is_some()); + let duplicate = + pane_restore_startup(None, Some(&custom), project_a, None, &mut agent_restore); + assert!(duplicate.restore_plan.is_none()); + assert!(duplicate.duplicate_agent_session); + + let mut resumed = HashSet::new(); + let mut disabled = AgentRestoreState { + enabled: false, + resumed_sessions: &mut resumed, + }; + let startup = pane_restore_startup( + None, + Some(&custom), + project_a, + Some(&history), + &mut disabled, + ); + assert!(startup.restore_plan.is_none()); + assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n")); + } + #[test] fn pane_restore_startup_keeps_history_without_native_agent_resume() { let session = super::super::snapshot::PaneAgentSessionSnapshot { @@ -1177,7 +1311,13 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); assert!(startup.restore_plan.is_none()); assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n")); @@ -1332,6 +1472,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "opencode-session".into(), }), + agent_resume: None, launch_argv: None, }, )]), @@ -1413,6 +1554,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ), @@ -1424,6 +1566,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ), @@ -1477,6 +1620,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ) @@ -1492,6 +1636,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "codex-session".into(), }), + agent_resume: None, launch_argv: None, }; let snapshot = SessionSnapshot { @@ -1643,6 +1788,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "codex-session".into(), }), + agent_resume: None, launch_argv: None, }, )]), @@ -1948,6 +2094,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index d7db3e97..3518ef41 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -108,9 +108,18 @@ pub struct PaneSnapshot { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option<PaneAgentSessionSnapshot>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_resume: Option<PaneAgentResumeSnapshot>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_argv: Option<Vec<String>>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneAgentResumeSnapshot { + pub source: String, + pub agent: String, + pub argv: Vec<String>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneAgentSessionSnapshot { pub source: String, @@ -362,6 +371,13 @@ fn capture_tab( value: session.session_ref.value.clone(), }) }); + let agent_resume = terminal + .and_then(|terminal| terminal.reported_resume()) + .map(|resume| PaneAgentResumeSnapshot { + source: resume.source.clone(), + agent: resume.agent.clone(), + argv: resume.argv.clone(), + }); panes.insert( id.raw(), PaneSnapshot { @@ -370,6 +386,7 @@ fn capture_tab( agent_name, managed_agent_kind, agent_session, + agent_resume, launch_argv, }, ); @@ -688,6 +705,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); @@ -699,6 +717,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); @@ -1286,6 +1305,40 @@ mod tests { assert_eq!(agent_session.value, session_path); } + #[test] + fn capture_contract_includes_reported_agent_resume() { + let mut state = state_with_workspaces(&["one"]); + let root = state.workspaces[0].tabs[0].root_pane; + state.ensure_test_terminals(); + let terminal_id = state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_hook_authority( + "prime-agent".into(), + "prime-agent".into(), + crate::detect::AgentState::Idle, + None, + Some(1), + ); + assert!(terminal.record_reported_resume( + "prime-agent", + "prime-agent", + Some(1), + vec!["prime-agent".into(), "--resume".into(), "a".into()], + )); + + let snapshot = capture_from_state(&state); + let resume = snapshot.workspaces[0].tabs[0].panes[&root.raw()] + .agent_resume + .as_ref() + .expect("reported resume should be captured"); + + assert_eq!(resume.source, "prime-agent"); + assert_eq!(resume.agent, "prime-agent"); + assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]); + } + #[test] fn capture_contract_preserves_restored_agent_session() { let mut state = state_with_workspaces(&["one"]); @@ -1350,6 +1403,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); @@ -1363,6 +1417,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 4def3fa4..bbb7bfe6 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -7289,6 +7289,142 @@ fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema: serde_json::from_str::<api::schema::SuccessResponse>(&response).expect("successful report"); } +#[test] +fn api_report_agent_stores_valid_resume_argv_and_rejects_invalid() { + let (writer, _control_rx, _render_rx) = test_client_writer(); + let (mut server, pane_id) = completion_guard_server(writer); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let report = |resume_argv: Vec<&str>| { + api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id.clone(), + source: "prime-agent".into(), + agent: "prime-agent".into(), + state: api::schema::PaneAgentState::Idle, + message: None, + seq: Some(1), + agent_session_id: Some("01a0".into()), + agent_session_path: None, + resume_argv: Some(resume_argv.into_iter().map(String::from).collect()), + }) + }; + + completion_guard_api_report( + &mut server, + report(vec!["prime-agent", "--resume", "01a0", "--model", "x"]), + ); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv, + vec!["prime-agent", "--resume", "01a0", "--model", "x"] + ); + + completion_guard_api_report(&mut server, report(vec!["prime-agent", "--resume", "dup"])); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv[2], + "01a0", + "a duplicate sequence number must not replace the command" + ); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "invalid-resume".into(), + method: report(vec!["/opt/prime/prime-agent", "--resume", "01a0"]), + }, + respond_to, + response_write_complete: None, + }); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + assert!(response.contains("invalid_resume_argv"), "{response}"); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv[0], + "prime-agent" + ); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "not-owner".into(), + method: api::schema::Method::PaneReportAgentSession( + api::schema::PaneReportAgentSessionParams { + pane_id: public_pane_id.clone(), + source: "custom:intruder".into(), + agent: "intruder".into(), + seq: None, + agent_session_id: None, + agent_session_path: None, + session_start_source: None, + resume_argv: Some(vec!["intruder".into()]), + }, + ), + }, + respond_to, + response_write_complete: None, + }); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + assert!(response.contains("resume_not_accepted"), "{response}"); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .agent, + "prime-agent" + ); +} + +#[test] +fn api_resume_argv_is_ignored_when_its_session_report_is_refused() { + let (writer, _control_rx, _render_rx) = test_client_writer(); + let (mut server, pane_id) = completion_guard_server(writer); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Claude, + observed_at: Instant::now(), + }); + let report = |session: &str| { + api::schema::Method::PaneReportAgentSession(api::schema::PaneReportAgentSessionParams { + pane_id: public_pane_id.clone(), + source: "herdr:claude".into(), + agent: "claude".into(), + seq: None, + agent_session_id: Some(session.into()), + agent_session_path: None, + session_start_source: None, + resume_argv: Some(vec!["claude".into(), "--resume".into(), session.into()]), + }) + }; + + completion_guard_api_report(&mut server, report("session-a")); + completion_guard_api_report(&mut server, report("session-b")); + + let terminal = &server.app.state.terminals[&terminal_id]; + assert!(terminal + .session_ref_is_current(&crate::agent_resume::AgentSessionRef::id("session-a").unwrap())); + assert_eq!( + terminal.reported_resume().unwrap().argv, + vec!["claude", "--resume", "session-a"] + ); +} + fn completion_guard_notifications( server: &mut HeadlessServer, receiver: &std::sync::mpsc::Receiver<Vec<u8>>, @@ -7347,6 +7483,7 @@ fn completion_guard_api_startup_blocker_respects_suppression() { seq: Some(seq as u64 + 1), agent_session_id: None, agent_session_path: None, + resume_argv: None, }), ); } @@ -7410,6 +7547,7 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() { seq: Some(11), agent_session_id: None, agent_session_path: Some(new_session.clone()), + resume_argv: None, session_start_source: Some(reason.into()), }), ); @@ -7422,6 +7560,7 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() { seq: Some(12), agent_session_id: None, agent_session_path: Some(new_session.clone()), + resume_argv: None, }; completion_guard_api_report(&mut server, Method::PaneReportAgent(report.clone())); let terminal = &server.app.state.terminals[&terminal_id]; @@ -7612,6 +7751,7 @@ fn stale_api_agent_report_does_not_forward_done_sound() { seq: Some(19), agent_session_id: None, agent_session_path: None, + resume_argv: None, }), }, respond_to, diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index ce7cab92..5f1c8a85 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -249,6 +249,10 @@ impl TerminalRuntime { self.0.set_full_lifecycle_authority_active(active); } + pub fn set_self_reported_agent_active(&self, active: bool) { + self.0.set_self_reported_agent_active(active); + } + pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { self.0.resize(rows, cols, cell_width_px, cell_height_px); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 4a5abb12..11d540b1 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -137,6 +137,8 @@ pub struct TerminalState { pub agent_metadata: HashMap<String, AgentMetadata>, pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>, + reported_resume: Option<crate::agent_resume::ReportedAgentResume>, + reported_resume_revision: u64, pub terminal_title: Option<String>, pub manual_label: Option<String>, pub agent_name: Option<String>, @@ -175,6 +177,8 @@ impl TerminalState { agent_metadata: HashMap::new(), metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, + reported_resume: None, + reported_resume_revision: 0, terminal_title: None, manual_label: None, agent_name: None, @@ -554,6 +558,15 @@ impl TerminalState { { self.persisted_agent_session = None; } + if !newer_custom_authority + && agent.is_some() + && self + .reported_resume + .as_ref() + .is_some_and(|resume| crate::detect::parse_agent_label(&resume.agent) == agent) + { + self.set_reported_resume(None); + } if let Some(agent) = agent { let agent_label = crate::detect::agent_label(agent); let mut cleared_metadata_sources = Vec::new(); @@ -1727,19 +1740,12 @@ impl TerminalState { } fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool { - let Some(seq) = seq else { - return !self.hook_report_sequences.contains_key(source); - }; - - if self - .hook_report_sequences - .get(source) - .is_some_and(|last_seq| seq <= *last_seq) - { + if !self.hook_report_is_newer(source, seq) { return false; } - - self.hook_report_sequences.insert(source.to_string(), seq); + if let Some(seq) = seq { + self.hook_report_sequences.insert(source.to_string(), seq); + } true } @@ -1785,7 +1791,9 @@ impl TerminalState { self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); - self.hook_authority = None; + if let Some(authority) = self.hook_authority.take() { + self.forget_reported_resume_of(&authority.source, &authority.agent_label); + } self.persisted_agent_session = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -1848,6 +1856,7 @@ impl TerminalState { self.clear_agent_name(); } self.hook_authority = None; + self.forget_reported_resume_of(source, agent_label); if !preserve_foreign_persisted_session { self.persisted_agent_session = None; } @@ -1865,6 +1874,161 @@ impl TerminalState { }) } + /// A reporter that Herdr cannot also identify by process holds this pane, + /// so only the pane returning to its shell can tell Herdr it exited. + pub fn self_reported_agent_active(&self) -> bool { + self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label).is_none() + }) + } + + /// `observed_at` is when the idle shell was seen; a claim made after that + /// belongs to a newer agent and must survive the delayed signal. + pub fn clear_self_reported_agent( + &mut self, + observed_at: Instant, + ) -> Option<TerminalStateMutation> { + if !self.self_reported_agent_active() || !self.hook_authority_not_newer_than(observed_at) { + return None; + } + let now = Instant::now(); + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); + let previous_session = self.current_session_identity_for_persistence(); + self.hook_authority = None; + self.set_reported_resume(None); + self.detected_agent = None; + self.fallback_state = AgentState::Unknown; + self.fallback_visible_blocker = false; + self.fallback_observed_at = None; + self.clear_agent_name(); + let current_session = self.current_session_identity_for_persistence(); + Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ), + session_ref_changed: previous_session != current_session, + agent_released: true, + }) + } + + pub fn session_ref_is_current( + &self, + session_ref: &crate::agent_resume::AgentSessionRef, + ) -> bool { + self.current_session_identity_for_persistence() + .is_some_and(|(_, _, kind, value)| { + kind == session_ref.kind && value == session_ref.value + }) + } + + /// A reporter may own the resume command when it holds the pane, or when it + /// is an agent Herdr currently sees running there and can therefore see exit. + pub fn can_record_reported_resume(&self, source: &str, agent_label: &str) -> bool { + match self.hook_authority.as_ref() { + Some(authority) => authority.source == source && authority.agent_label == agent_label, + None => { + self.recent_agent_process_exit.is_none() + && self.detected_agent.is_some() + && crate::detect::parse_agent_label(agent_label) == self.detected_agent + } + } + } + + /// Same ordering rule as lifecycle reports, evaluated before the report is + /// applied so a duplicate cannot pass as the report that was just accepted. + pub fn hook_report_is_newer(&self, source: &str, seq: Option<u64>) -> bool { + let last_seq = self.hook_report_sequences.get(source).copied(); + match seq { + Some(seq) => last_seq.is_none_or(|last| seq > last), + None => last_seq.is_none(), + } + } + + /// Callers must first check `hook_report_is_newer` against the state + /// before the carrying report was applied. + pub fn record_reported_resume( + &mut self, + source: &str, + agent_label: &str, + seq: Option<u64>, + argv: Vec<String>, + ) -> bool { + if !self.can_record_reported_resume(source, agent_label) { + return false; + } + if let Some(seq) = seq { + let last = self + .hook_report_sequences + .entry(source.to_string()) + .or_insert(seq); + *last = (*last).max(seq); + } + let resume = crate::agent_resume::ReportedAgentResume { + source: source.to_string(), + agent: agent_label.to_string(), + argv, + }; + if self.reported_resume.as_ref() == Some(&resume) { + return false; + } + self.set_reported_resume(Some(resume)); + true + } + + pub fn reported_resume(&self) -> Option<&crate::agent_resume::ReportedAgentResume> { + self.reported_resume.as_ref() + } + + /// Changes whenever the stored resume command changes, so callers can + /// persist removals as well as new commands. + pub fn reported_resume_revision(&self) -> u64 { + self.reported_resume_revision + } + + /// Drops the command once a different agent holds the pane. + pub fn reconcile_reported_resume(&mut self) { + let Some(resume) = self.reported_resume.as_ref() else { + return; + }; + let other_authority = self.hook_authority.as_ref().is_some_and(|authority| { + authority.source != resume.source || authority.agent_label != resume.agent + }); + let other_process = self + .detected_agent + .is_some_and(|agent| crate::detect::agent_label(agent) != resume.agent); + if other_authority || other_process { + self.set_reported_resume(None); + } + } + + pub fn restore_reported_resume(&mut self, resume: crate::agent_resume::ReportedAgentResume) { + self.set_reported_resume(Some(resume)); + } + + fn set_reported_resume(&mut self, resume: Option<crate::agent_resume::ReportedAgentResume>) { + if self.reported_resume != resume { + self.reported_resume = resume; + self.reported_resume_revision += 1; + } + } + + fn forget_reported_resume_of(&mut self, source: &str, agent_label: &str) { + if self + .reported_resume + .as_ref() + .is_some_and(|resume| resume.source == source && resume.agent == agent_label) + { + self.set_reported_resume(None); + } + } + fn hook_authority_is_effective(&self, authority: &HookAuthority) -> bool { !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) || crate::detect::parse_agent_label(&authority.agent_label).is_none_or(|agent| { From 25aaa0b7e31b0b271ce72a69c9dd272d7ee83376 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Sun, 27 Sep 2026 21:05:25 +0300 Subject: [PATCH 77/99] fix: keep offscreen kitty images loaded instead of resending them on scroll (#4686) --- crates/ghostty-vt/src/lib.rs | 79 ++++++-- src/kitty_graphics.rs | 16 +- src/kitty_graphics/surface.rs | 107 +++++++++- src/pane.rs | 4 + src/pane/terminal.rs | 12 ++ src/server/headless/tests/pane_graphics.rs | 217 ++++++++++++++++++++- src/terminal/runtime.rs | 4 + 7 files changed, 412 insertions(+), 27 deletions(-) diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index ef62ef1e..056ff142 100644 --- a/crates/ghostty-vt/src/lib.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}; @@ -1824,6 +1824,37 @@ impl Terminal { 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<Vec<Option<u64>>, 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<Vec<KittyImagePlacement>, Error> { self.kitty_image_placements_with_data_filter(|_| true) } @@ -1872,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) } @@ -1923,16 +1954,13 @@ 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<u32> = 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() + }); } } @@ -3850,6 +3878,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(); diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index e69b9df3..f26cd8d9 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -335,16 +335,20 @@ fn collect_visible_placements( } }; let mut requested_images = HashSet::new(); - for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| { + let pane_placements = runtime.kitty_image_placements_with_data_filter(|descriptor| { if descriptor.source_file { return false; } terminal_image_needs_data(info.id, descriptor, delivered_images, &mut requested_images) - }) { - let scrollback_offset = runtime - .scroll_metrics() - .map(|m| m.offset_from_bottom as u32) - .unwrap_or(0); + }); + if pane_placements.is_empty() { + continue; + } + let scrollback_offset = runtime + .scroll_metrics() + .map(|m| m.offset_from_bottom as u32) + .unwrap_or(0); + for placement in pane_placements { placements.push(HostPlacement { raw_data: None, pane_id: info.id, diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index ee6d6e97..09db2286 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -20,10 +20,17 @@ use crate::protocol::{ }; const MAX_SURFACE_GRAPHICS_PLACEMENTS: usize = 4_096; +// Apps such as pi scroll images out of view and back. Keeping a bounded set +// loaded on the host avoids resending their pixels on every pass. The limits +// match pi's own off-screen cache in decoded bytes, which is what hosts store. +const MAX_OFFSCREEN_IMAGES: usize = 16; +const MAX_OFFSCREEN_IMAGE_BYTES: u64 = 64 * 1024 * 1024; #[derive(Clone, Debug, Default)] pub(crate) struct DeliveryCache { assets: HashSet<SurfaceGraphicsAssetKey>, + /// Delivered terminal images without a placement, most recently visible first. + offscreen: Vec<SurfaceGraphicsAssetKey>, pending: bool, } @@ -33,6 +40,7 @@ pub(crate) type SourceFiles = impl DeliveryCache { pub(crate) fn forget_asset(&mut self, key: &SurfaceGraphicsAssetKey) { self.assets.remove(key); + self.offscreen.retain(|offscreen| offscreen != key); self.pending = true; } @@ -730,8 +738,15 @@ pub(crate) fn collect_scene( .iter() .map(|placement| placement.asset.clone()) .collect::<HashSet<_>>(); + let offscreen = offscreen_assets(app, workspace_index, &public_panes, delivered, &desired); let mut next = DeliveryCache { - assets: delivered.assets.intersection(&desired).cloned().collect(), + assets: delivered + .assets + .intersection(&desired) + .chain(&offscreen) + .cloned() + .collect(), + offscreen: offscreen.clone(), pending: false, }; let mut assets = Vec::new(); @@ -769,19 +784,105 @@ pub(crate) fn collect_scene( placement.x, ) }); - let retained_assets = Vec::new(); sources.retain(|key, _| next.assets.contains(key)); ( SurfaceGraphicsScene { assets, placements, - retained_assets, + retained_assets: offscreen, }, next, sources, ) } +/// Delivered pane images that left the view but still exist in their terminal, +/// bounded and ordered from most to least recently visible. +fn offscreen_assets( + app: &crate::app::App, + workspace_index: Option<usize>, + public_panes: &HashMap<String, PaneId>, + delivered: &DeliveryCache, + visible: &HashSet<SurfaceGraphicsAssetKey>, +) -> Vec<SurfaceGraphicsAssetKey> { + let Some(workspace_index) = workspace_index else { + return Vec::new(); + }; + fn pane_image(key: &SurfaceGraphicsAssetKey) -> Option<(&str, u32)> { + match &key.source { + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { pane_id }, + image_id, + } => Some((pane_id.as_str(), *image_id)), + _ => None, + } + } + let mut newly_hidden = delivered + .assets + .iter() + .filter(|key| !visible.contains(*key) && !delivered.offscreen.contains(key)) + .collect::<Vec<_>>(); + if newly_hidden.is_empty() && delivered.offscreen.is_empty() { + return Vec::new(); + } + newly_hidden.sort_by_key(|key| pane_image(key)); + let visible_sources = visible + .iter() + .map(|key| &key.source) + .collect::<HashSet<_>>(); + let mut candidates_by_pane = HashMap::<PaneId, Vec<&SurfaceGraphicsAssetKey>>::new(); + let candidates = newly_hidden + .into_iter() + .chain(&delivered.offscreen) + .filter(|key| { + !visible_sources.contains(&key.source) && key.data_len <= MAX_OFFSCREEN_IMAGE_BYTES + }) + .filter_map(|key| { + let (public_id, _) = pane_image(key)?; + let pane_id = *public_panes.get(public_id)?; + candidates_by_pane.entry(pane_id).or_default().push(key); + Some(key) + }) + .collect::<Vec<_>>(); + + let mut live = HashSet::new(); + for (pane_id, keys) in candidates_by_pane { + let Some(runtime) = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + ) else { + continue; + }; + let image_ids = keys + .iter() + .filter_map(|key| pane_image(key).map(|(_, image_id)| image_id)) + .collect::<Vec<_>>(); + for (key, fingerprint) in keys + .into_iter() + .zip(runtime.kitty_image_fingerprints(&image_ids)) + { + if fingerprint == Some(key.data_fingerprint) { + live.insert(key); + } + } + } + + let mut bytes = 0u64; + let mut offscreen = Vec::new(); + for key in candidates { + if offscreen.len() == MAX_OFFSCREEN_IMAGES { + break; + } + if !live.contains(key) || bytes.saturating_add(key.data_len) > MAX_OFFSCREEN_IMAGE_BYTES { + continue; + } + bytes += key.data_len; + offscreen.push(key.clone()); + } + offscreen +} + fn image_signature_from_asset(key: &SurfaceGraphicsAssetKey) -> ImageSignature { ImageSignature { image_width: key.image_width, diff --git a/src/pane.rs b/src/pane.rs index e5695675..8fd8e396 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3496,6 +3496,10 @@ impl PaneRuntime { .kitty_image_placements_with_data_filter(needs_data) } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.terminal.kitty_image_fingerprints(image_ids) + } + pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { let fallback = crate::input::KeyboardProtocol::from_kitty_flags( self.kitty_keyboard_flags.load(Ordering::Relaxed), diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 5eaddb21..8ac4bd1c 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -579,6 +579,10 @@ impl PaneTerminal { .kitty_image_placements_with_data_filter(needs_data) } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.ghostty.kitty_image_fingerprints(image_ids) + } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.ghostty.apply_host_terminal_theme(theme); } @@ -2349,6 +2353,14 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.core + .lock() + .ok() + .and_then(|core| core.terminal.kitty_image_fingerprints(image_ids).ok()) + .unwrap_or_else(|| vec![None; image_ids.len()]) + } + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { let Ok(mut core) = self.core.lock() else { return; diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 190eaa80..1255f4eb 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -96,6 +96,73 @@ async fn first_kitty_image_updates_retained_surface_without_full_redraw() { assert_eq!(recovered.graphics.assets[0].data, [255, 0, 0, 255]); } +#[tokio::test] +async fn offscreen_images_keep_text_updates_on_the_retained_path() { + let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control( + b"\x1b_Ga=T,f=32,t=d,i=7,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + let sources = HashSet::from([pane_id]); + server.render_and_stream(); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + + write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(hidden) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected hidden image scene"); + }; + assert!(hidden.graphics.placements.is_empty()); + assert_eq!(hidden.graphics.retained_assets.len(), 1); + + // Clients reject row patches while images are retained, so the text must + // arrive in a surface that keeps the hidden image without resending it. + write_shared_test_pane(&mut server, pane_id, b"\rtext while hidden"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(text_update) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected a surface the client accepts while images are retained"); + }; + assert!(frame_text(&text_update.frame).contains("text while hidden")); + assert!(text_update.graphics.placements.is_empty()); + assert!(text_update.graphics.assets.is_empty()); + assert_eq!( + text_update.graphics.retained_assets, + hidden.graphics.retained_assets + ); + + write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(shown) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected the hidden image to return"); + }; + assert_eq!(shown.graphics.placements.len(), 1); + assert!(shown.graphics.assets.is_empty()); + + write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\"); + assert!(server.render_retained_pane_surface_and_stream(&sources)); + let ServerMessage::PaneSurface(deleted) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected the deleted image to be released"); + }; + assert!(deleted.graphics.retained_assets.is_empty()); +} + #[tokio::test] async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() { let (mut server, _control_rx, client_rx, pane_id) = @@ -174,7 +241,7 @@ async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload async fn render_scale_profile_retained_graphics() { use ratatui::layout::Direction; for retained in [false, true] { - for with_image in [false, true] { + for image in ["none", "visible", "offscreen"] { for count in [1, 15] { let (mut server, _control_rx, client_rx, root) = retained_test_server_with_control(b"populated terminal\r\n"); @@ -205,7 +272,7 @@ async fn render_scale_profile_retained_graphics() { width_px: 10, height_px: 20, }; - if with_image { + if image != "none" { write_shared_test_pane( &mut server, root, @@ -214,6 +281,11 @@ async fn render_scale_profile_retained_graphics() { } server.render_and_stream(); let _ = receive_render(&client_rx, Duration::from_millis(100)); + if image == "offscreen" { + write_shared_test_pane(&mut server, root, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + server.render_and_stream(); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + } let sources = pane_ids.iter().copied().collect(); let mut samples = Vec::new(); for sample in 0..110 { @@ -242,10 +314,10 @@ async fn render_scale_profile_retained_graphics() { } samples.sort_unstable(); println!( - "retained={retained} 80x24 panes={count} image={with_image} median_us={} p95_us={}", - samples[50].as_micros(), - samples[94].as_micros() - ); + "retained={retained} 80x24 panes={count} image={image} median_us={} p95_us={}", + samples[50].as_micros(), + samples[94].as_micros() + ); } } } @@ -322,6 +394,139 @@ async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { assert!(surface.graphics.assets.is_empty()); } +#[tokio::test] +async fn client_shell_keeps_offscreen_terminal_image_loaded_until_the_image_is_deleted() { + let (mut server, _control_rx, client_rx, pane) = retained_test_server_with_control( + b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;/wAA/w==\x1b\\\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\", + ); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + let render = |server: &mut HeadlessServer| { + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + surface.graphics + }; + + let shown = render(&mut server); + assert_eq!(shown.placements.len(), 1); + assert_eq!(shown.assets.len(), 1); + let key = shown.assets[0].key.clone(); + + // Scrolling an image out of view removes its placement but keeps the image. + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + let hidden = render(&mut server); + assert!(hidden.placements.is_empty()); + assert!(hidden.assets.is_empty()); + assert_eq!(hidden.retained_assets, vec![key.clone()]); + + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\"); + let returned = render(&mut server); + assert_eq!(returned.placements.len(), 1); + assert_eq!(returned.placements[0].asset, key); + assert!( + returned.assets.is_empty(), + "an image the client still holds must not be sent again" + ); + + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + assert_eq!(render(&mut server).retained_assets, vec![key.clone()]); + + // Replacing a hidden image with same-sized pixels releases the stale copy. + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;AAD//w==\x1b\\", + ); + assert!(render(&mut server).retained_assets.is_empty()); + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\"); + let replaced_hidden = render(&mut server); + assert_eq!(replaced_hidden.assets.len(), 1); + assert_eq!(replaced_hidden.assets[0].data, vec![0, 0, 255, 255]); + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + assert_eq!(render(&mut server).retained_assets.len(), 1); + + // Deleting a hidden image releases it on the host. + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\"); + let deleted = render(&mut server); + assert!(deleted.placements.is_empty()); + assert!(deleted.retained_assets.is_empty()); + + // Reusing the id for new pixels delivers them instead of the old image. + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=T,f=32,t=d,i=7,s=1,v=1,c=1,r=1,q=2;AP8A/w==\x1b\\", + ); + let replaced = render(&mut server); + assert_eq!(replaced.assets.len(), 1); + assert_eq!(replaced.assets[0].data, vec![0, 255, 0, 255]); + assert_ne!(replaced.assets[0].key, key); +} + +#[tokio::test] +async fn client_shell_evicts_least_recently_visible_offscreen_terminal_images() { + let mut screen = Vec::new(); + for image_id in 1..=17 { + screen.extend_from_slice( + format!("\x1b_Ga=T,f=32,t=d,i={image_id},s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\") + .as_bytes(), + ); + } + let (mut server, _control_rx, client_rx, pane) = retained_test_server_with_control(&screen); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + let render = |server: &mut HeadlessServer| { + server.render_and_stream(); + let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); + let ServerMessage::PaneSurface(surface) = message else { + panic!("expected client shell pane surface"); + }; + surface.graphics + }; + let retained_ids = |graphics: &crate::protocol::SurfaceGraphicsScene| { + graphics + .retained_assets + .iter() + .map(|key| match key.source { + crate::protocol::SurfaceGraphicsSource::Terminal { image_id, .. } => image_id, + _ => panic!("expected terminal image"), + }) + .collect::<Vec<_>>() + }; + assert_eq!(render(&mut server).assets.len(), 17); + + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + let hidden = render(&mut server); + assert!(hidden.placements.is_empty()); + assert_eq!(retained_ids(&hidden), (1..=16).collect::<Vec<_>>()); + + // Image 17 was evicted, so showing it sends its pixels again. + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=17,c=1,r=1,q=2\x1b\\"); + assert_eq!(render(&mut server).assets.len(), 1); + + // The most recently visible image is kept first; the oldest is evicted. + write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); + let rehidden = render(&mut server); + let mut expected = vec![17]; + expected.extend(1..=15); + assert_eq!(retained_ids(&rehidden), expected); +} + fn fill_render_lane(server: &HeadlessServer) { let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("dummy frame"); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 5f1c8a85..91713409 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -458,6 +458,10 @@ impl TerminalRuntime { self.0.kitty_image_placements_with_data_filter(needs_data) } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.0.kitty_image_fingerprints(image_ids) + } + pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { self.0.keyboard_protocol() } From 21d71a0308c3df7053f9fc281ad4943d1c89d1a3 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik <ogulcancelik@gmail.com> Date: Sun, 27 Sep 2026 21:09:35 +0300 Subject: [PATCH 78/99] docs: add a guide for adding herdr support to your agent --- .../src/content/docs/add-herdr-support.mdx | 117 ++++++++++++++++++ docs/next/website/src/content/docs/agents.mdx | 2 +- .../website/src/content/docs/integrations.mdx | 45 +------ .../src/content/docs/ja/add-herdr-support.mdx | 117 ++++++++++++++++++ .../website/src/content/docs/ja/agents.mdx | 2 +- .../src/content/docs/ja/cli-reference.mdx | 8 +- .../src/content/docs/ja/integrations.mdx | 31 +---- .../src/content/docs/ja/session-state.mdx | 2 +- .../src/content/docs/ja/socket-api.mdx | 16 +++ .../src/content/docs/session-state.mdx | 2 +- .../content/docs/zh-cn/add-herdr-support.mdx | 117 ++++++++++++++++++ .../website/src/content/docs/zh-cn/agents.mdx | 2 +- .../src/content/docs/zh-cn/cli-reference.mdx | 8 +- .../src/content/docs/zh-cn/integrations.mdx | 31 +---- .../src/content/docs/zh-cn/session-state.mdx | 2 +- .../src/content/docs/zh-cn/socket-api.mdx | 16 +++ 16 files changed, 413 insertions(+), 105 deletions(-) create mode 100644 docs/next/website/src/content/docs/add-herdr-support.mdx create mode 100644 docs/next/website/src/content/docs/ja/add-herdr-support.mdx create mode 100644 docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx diff --git a/docs/next/website/src/content/docs/add-herdr-support.mdx b/docs/next/website/src/content/docs/add-herdr-support.mdx new file mode 100644 index 00000000..0ed689a3 --- /dev/null +++ b/docs/next/website/src/content/docs/add-herdr-support.mdx @@ -0,0 +1,117 @@ +--- +title: Add Herdr support to your agent +description: Report your coding agent's state and resume command to Herdr from your own code, without adding native support to Herdr. +--- + +If you build a coding agent, you can make it a first-class citizen in Herdr from your own code. You don't need a pull request to Herdr, and you don't need to wait for a Herdr release. + +Once your agent reports to Herdr, users get: + +- your agent's name and `idle`, `working`, or `blocked` state in the sidebar and in `herdr agent list` +- notifications when it finishes or needs a decision +- `herdr agent wait` and other automation that waits on agent state +- the same session back in the same pane after a Herdr server restart + +Herdr owns the integrations for agents whose vendors don't ship one. If you maintain your agent, owning the integration yourself means you can fix it on your own release schedule. + +## How it works + +Every process in a Herdr pane inherits these environment variables: + +| Variable | Meaning | +| --- | --- | +| `HERDR_ENV` | `1` inside Herdr | +| `HERDR_PANE_ID` | The pane your agent runs in | +| `HERDR_BIN_PATH` | The Herdr binary that owns this pane | +| `HERDR_SOCKET_PATH` | Herdr's API socket | + +Your agent makes three kinds of reports through `"$HERDR_BIN_PATH"`: + +1. **State.** Report `idle`, `working`, or `blocked` whenever it changes. +2. **Resume command.** Report the command that reopens the current session. +3. **Release.** Release the pane when your agent exits. + +Only report when `HERDR_ENV=1` and the other variables are set. Outside Herdr, your integration should do nothing. + +## Report state + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state working \ + --seq 1 +``` + +- Report `working` when a turn starts, `idle` when your agent is ready for input, and `blocked` when it needs the user to decide something. Add `--message` to explain a block. +- `--agent` is the name users see. Use your own agent's name, not the name of an agent Herdr already supports. +- `--source` identifies your integration. Keep it stable and unique. Don't start it with `herdr:`, because Herdr's own integrations use that prefix. +- `--seq` is optional but recommended. It must increase with every report from your source, including across sessions and restarts of your agent. A timestamp works well. Herdr ignores reports whose number is not higher than the last one it accepted, so late or out-of-order reports can't overwrite newer state. + +## Report the resume command + +Put the command that resumes the current session after `--`. Include the options that session needs, such as the model or permission mode, so the resumed session behaves the same: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state idle \ + --seq 2 \ + --agent-session-id "$SESSION_ID" \ + -- my-agent --resume "$SESSION_ID" --model my-model +``` + +You can attach the command to any state report, or send it with `pane report-agent-session` when only the session changes. Report it again whenever your agent switches sessions. + +After a Herdr server restart, Herdr opens the pane in the same directory and runs that command there. The command must follow these rules: + +- The first word is a plain command name on the user's `PATH`, such as `my-agent`, not a path. +- No argument contains an apostrophe. +- Your source must hold the pane first, so send a state report with `report-agent` before or along with the command. Otherwise Herdr answers with `resume_not_accepted`. + +Herdr rejects a command that breaks the first two rules with `invalid_resume_argv` and does not apply that report. Users can turn off resume with `[session] resume_agents_on_restore = false`. + +## Release on exit + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --seq 3 +``` + +Releasing clears your agent's name, state, and resume command from the pane right away. Only release when the user actually quits. If your agent replaces one session with another in the same process, report the new session instead of releasing. + +If your agent exits without releasing, Herdr notices once the pane is back at its idle shell prompt and clears the agent and its resume command. This usually takes a second or two. It is a safety net, not a replacement for releasing. + +## Keep it out of the way + +- Don't let Herdr slow your agent down. Send reports in the background or with a short timeout, and ignore failures. +- Send only the latest state. If several changes queue up while a report is in flight, drop the older ones. +- Older Herdr versions ignore the resume command, so everything else keeps working there. + +## Use the socket directly + +`HERDR_BIN_PATH` and the CLI are the portable choice, including on Windows. If your agent needs direct IPC, send the equivalent `pane.report_agent`, `pane.report_agent_session`, and `pane.release_agent` requests described in the [Socket API](/docs/socket-api/#agent-state-reporting). The resume command goes in the `resume_argv` array. + +## Check your integration + +Start your agent in a Herdr pane, then check what Herdr sees: + +```bash +herdr pane get "$HERDR_PANE_ID" +herdr agent list +``` + +To test restore without touching your normal session, use a separate named session: + +1. Start `herdr --session my-agent-test`, then run your agent in a pane. +2. Stop that session with `herdr session stop my-agent-test`. +3. Start `herdr --session my-agent-test` again. The pane should run your resume command and your agent should report again. + +## Example + +[Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world integration. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, keeps its report order across sessions, and releases the pane on exit. + +To change how your agent appears without changing its state, such as a title or custom status text, see [Custom status labels](/docs/integrations/#custom-status-labels). diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index 78274850..5c4351a5 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -116,7 +116,7 @@ herdr integration install claude herdr integration status ``` -Each supported agent has its own integration name and behavior. See [Integrations](/docs/integrations/) for the per-agent details and the full install list. If you are building an agent, the [custom integration guide](/docs/integrations/#integrate-your-own-agent) shows how to report lifecycle state without adding native support to Herdr. +Each supported agent has its own integration name and behavior. See [Integrations](/docs/integrations/) for the per-agent details and the full install list. If you are building an agent, [Add Herdr support to your agent](/docs/add-herdr-support/) shows how to report lifecycle state and a resume command without adding native support to Herdr. ## Custom agent labels diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index e36dfdf2..3aff5ae1 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -66,51 +66,14 @@ Herdr uses integrations in two ways: Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable. -## Integrate your own agent - -An agent running in a Herdr pane inherits `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_BIN_PATH`, and `HERDR_SOCKET_PATH`. If the agent exposes lifecycle hooks, use those hooks to report semantic state through Herdr's CLI: - -```bash -"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent \ - --state working -``` - -Report `idle` when the agent is ready for input and `blocked` when it needs a user decision. Use `--message` to describe a block. When the agent exits, release the same source's lifecycle authority: - -```bash -"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent -``` - -Report only when `HERDR_ENV=1` and the required variables are present. This keeps the integration a no-op outside Herdr. Keep `--source` stable and unique to the integration. If reports can arrive out of order, include a strictly increasing `--seq`; Herdr ignores stale sequence numbers from the same source. - -You can include `--agent-session-id` or `--agent-session-path` with `report-agent`, or use `pane report-agent-session` when session identity changes independently of state. Herdr exposes that reference through its pane and agent APIs. - -To have Herdr resume your agent after a Herdr server restart, report the command that resumes the current session after `--`. Include any options the session needs, such as the model or permission mode: - -```bash -"$HERDR_BIN_PATH" pane report-agent-session "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent \ - --agent-session-id "$SESSION_ID" \ - -- my-agent --resume "$SESSION_ID" --model my-model -``` - -Herdr runs that command in the restored pane's directory. The first word must be a plain command name on `PATH`, not a path, and no argument may contain an apostrophe. Report the agent's state with `report-agent` first so your source holds the pane, and report the command again whenever the session changes. Herdr keeps it only while your source holds the pane and drops it on release. `[session] resume_agents_on_restore = false` disables it. - -Herdr cannot identify a custom agent's process, so it treats the pane returning to its idle shell prompt as the agent exiting and clears the agent and its resume command. Still release authority on exit so the pane updates immediately. - -Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that needs direct IPC can send the equivalent `pane.report_agent`, `pane.report_agent_session`, and `pane.release_agent` requests described in the [Socket API](/docs/socket-api/#agent-state-reporting). - -[Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world example. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, preserves report ordering across sessions, and releases authority on exit. - Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. +## Integrate your own agent + +If you build a coding agent, you can report its state and resume command to Herdr from your own code, without adding native support to Herdr. See [Add Herdr support to your agent](/docs/add-herdr-support/). + ## Pi Install the Pi integration: diff --git a/docs/next/website/src/content/docs/ja/add-herdr-support.mdx b/docs/next/website/src/content/docs/ja/add-herdr-support.mdx new file mode 100644 index 00000000..d095bc2d --- /dev/null +++ b/docs/next/website/src/content/docs/ja/add-herdr-support.mdx @@ -0,0 +1,117 @@ +--- +title: エージェントに Herdr 対応を追加する +description: Herdr にネイティブサポートを追加せずに、自分のコードからコーディングエージェントの状態と resume コマンドを Herdr に報告します。 +--- + +コーディングエージェントを開発しているなら、自分のコードだけでそのエージェントを Herdr の第一級の存在にできます。Herdr へのプルリクエストも、Herdr のリリースを待つ必要もありません。 + +エージェントが Herdr に報告すると、ユーザーは次のものを得られます: + +- サイドバーと `herdr agent list` に表示されるエージェント名と `idle`、`working`、`blocked` の状態 +- 完了したときや判断が必要になったときの通知 +- `herdr agent wait` など、エージェントの状態を待つ自動化 +- Herdr サーバーの再起動後、同じペインで同じセッションが戻ること + +ベンダーがインテグレーションを提供していないエージェントについては、Herdr がインテグレーションを管理します。エージェントのメンテナーが自分でインテグレーションを持てば、自分のリリーススケジュールで修正できます。 + +## 仕組み + +Herdr ペイン内のすべてのプロセスは、次の環境変数を継承します: + +| 変数 | 意味 | +| --- | --- | +| `HERDR_ENV` | Herdr 内では `1` | +| `HERDR_PANE_ID` | エージェントが動いているペイン | +| `HERDR_BIN_PATH` | このペインを管理する Herdr バイナリ | +| `HERDR_SOCKET_PATH` | Herdr の API ソケット | + +エージェントは `"$HERDR_BIN_PATH"` を通じて 3 種類の報告をします: + +1. **状態。** `idle`、`working`、`blocked` が変わるたびに報告します。 +2. **resume コマンド。** 現在のセッションを開き直すコマンドを報告します。 +3. **解放。** エージェントの終了時にペインを解放します。 + +`HERDR_ENV=1` で他の変数も設定されているときだけ報告してください。Herdr の外では、インテグレーションは何もしないようにします。 + +## 状態を報告する + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state working \ + --seq 1 +``` + +- ターンの開始時に `working`、入力を受け付けられるときに `idle`、ユーザーの判断が必要なときに `blocked` を報告します。ブロックの理由は `--message` で説明できます。 +- `--agent` はユーザーに表示される名前です。Herdr がすでに対応しているエージェントの名前ではなく、自分のエージェントの名前を使ってください。 +- `--source` はインテグレーションを識別します。固定かつ一意にしてください。`herdr:` で始めないでください。この接頭辞は Herdr 自身のインテグレーションが使います。 +- `--seq` は省略できますが、付けることを推奨します。source からの報告ごとに増加する必要があり、エージェントのセッションや再起動をまたいでも増加し続ける必要があります。タイムスタンプが適しています。Herdr は最後に受け付けた番号より大きくない報告を無視するので、遅れて届いた報告や順不同の報告が新しい状態を上書きすることはありません。 + +## resume コマンドを報告する + +現在のセッションを再開するコマンドを `--` の後に置きます。モデルや権限モードなど、そのセッションに必要なオプションも含めると、再開したセッションが同じように動きます: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state idle \ + --seq 2 \ + --agent-session-id "$SESSION_ID" \ + -- my-agent --resume "$SESSION_ID" --model my-model +``` + +コマンドはどの状態報告に付けてもかまいません。セッションだけが変わったときは `pane report-agent-session` で送れます。エージェントがセッションを切り替えるたびに、もう一度報告してください。 + +Herdr サーバーの再起動後、Herdr は同じディレクトリでペインを開き、そこでそのコマンドを実行します。コマンドは次のルールに従う必要があります: + +- 最初の語は、`my-agent` のようにユーザーの `PATH` 上にある単純なコマンド名にします。パスは使えません。 +- どの引数にもアポストロフィを含めません。 +- 先に source がペインを保持している必要があります。コマンドより前か同時に、`report-agent` で状態を報告してください。そうしないと Herdr は `resume_not_accepted` を返します。 + +最初の 2 つのルールに違反するコマンドは、Herdr が `invalid_resume_argv` で拒否し、その報告は適用されません。ユーザーは `[session] resume_agents_on_restore = false` で resume を無効にできます。 + +## 終了時に解放する + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --seq 3 +``` + +解放すると、エージェントの名前、状態、resume コマンドがすぐにペインから消えます。解放するのはユーザーが実際に終了したときだけにしてください。同じプロセス内でセッションを別のセッションに置き換える場合は、解放せずに新しいセッションを報告します。 + +エージェントが解放せずに終了した場合、Herdr はペインがアイドルなシェルプロンプトに戻ったことに気づき、エージェントと resume コマンドを消去します。通常は 1〜2 秒かかります。これは安全網であり、解放の代わりにはなりません。 + +## エージェントの邪魔をしない + +- Herdr のせいでエージェントを遅くしないでください。報告はバックグラウンドか短いタイムアウトで送り、失敗は無視します。 +- 最新の状態だけを送ります。報告の送信中に複数の変化がたまったら、古いものは捨てます。 +- 古い Herdr は resume コマンドを無視するので、それ以外の機能はそのまま動きます。 + +## ソケットを直接使う + +`HERDR_BIN_PATH` と CLI は Windows を含めて移植性のある選択肢です。エージェントに直接の IPC が必要な場合は、[Socket API](/ja/docs/socket-api/) に記載された同等の `pane.report_agent`、`pane.report_agent_session`、`pane.release_agent` リクエストを送信します。resume コマンドは `resume_argv` 配列に入れます。 + +## インテグレーションを確認する + +Herdr のペインでエージェントを起動し、Herdr から見える内容を確認します: + +```bash +herdr pane get "$HERDR_PANE_ID" +herdr agent list +``` + +普段のセッションに影響を与えずに復元をテストするには、別の名前付きセッションを使います: + +1. `herdr --session my-agent-test` を起動し、ペインでエージェントを実行します。 +2. `herdr session stop my-agent-test` でそのセッションを停止します。 +3. `herdr --session my-agent-test` をもう一度起動します。ペインで resume コマンドが実行され、エージェントが再び報告するはずです。 + +## 例 + +[Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際のインテグレーションです。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時にペインを解放します。 + +タイトルやカスタムのステータス文言など、状態を変えずにエージェントの見た目を変えるには、[カスタムステータスラベル](/ja/docs/integrations/#カスタムステータスラベル)を参照してください。 diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 528ab0e4..cb71a678 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -114,7 +114,7 @@ herdr integration install claude herdr integration status ``` -対応エージェントごとに、インテグレーションの名前と挙動は異なります。エージェント別の詳細と完全なインストール一覧は[インテグレーション](/ja/docs/integrations/)を参照してください。エージェントを開発している場合は、[カスタムインテグレーションガイド](/ja/docs/integrations/#独自エージェントを統合する)で、Herdr にネイティブサポートを追加せずにライフサイクル状態を報告する方法を確認できます。 +対応エージェントごとに、インテグレーションの名前と挙動は異なります。エージェント別の詳細と完全なインストール一覧は[インテグレーション](/ja/docs/integrations/)を参照してください。エージェントを開発している場合は、[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)で、Herdr にネイティブサポートを追加せずにライフサイクル状態と resume コマンドを報告する方法を確認できます。 ## カスタムエージェントラベル diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index b622c245..2904b069 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -258,7 +258,8 @@ herdr pane report-agent <pane_id> \ [--message TEXT] \ [--seq N] \ [--agent-session-id ID] \ - [--agent-session-path PATH] + [--agent-session-path PATH] \ + [-- RESUME_COMMAND...] herdr pane report-agent-session <pane_id> \ --source ID \ @@ -266,7 +267,8 @@ herdr pane report-agent-session <pane_id> \ [--seq N] \ [--agent-session-id ID] \ [--agent-session-path PATH] \ - [--session-start-source SOURCE] + [--session-start-source SOURCE] \ + [-- RESUME_COMMAND...] herdr pane release-agent <pane_id> \ --source ID \ @@ -276,6 +278,8 @@ herdr pane release-agent <pane_id> \ `report-agent-session` はライフサイクル状態を報告せずにネイティブセッション識別子を更新します。`release-agent` は、そのエージェントプロセスが終了したときに、そのソースのライフサイクル権限を終了します。 +`--` の後の引数は、サーバー再起動後にこのセッションを再開するために Herdr が実行するコマンドです。最初の引数は `PATH` 上の単純なコマンド名でなければならず、どの引数にもアポストロフィを含めることはできません。 + 公式インテグレーションがネイティブセッション参照を報告している場合、`pane get`、`pane list`、`agent get`、`agent list` は読み取り専用の `agent_session` オブジェクトを含みます。ネイティブセッション参照が保存されていない場合、このフィールドは省略されます。 これらのコマンドは、ペインを制御しているフォアグラウンドプロセスの cwd を解決できる場合に `foreground_cwd` を含みます。既存の `cwd` フィールドは、ラベルと follow-cwd 挙動に使われるペイン/ワークスペースの cwd のままです。 diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index d54eaa88..126ba824 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -68,37 +68,14 @@ Herdr はインテグレーションを 2 つの異なる方法で使います: カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。 -## 独自エージェントを統合する - -Herdr ペイン内で動くエージェントは、`HERDR_ENV`、`HERDR_PANE_ID`、`HERDR_BIN_PATH`、`HERDR_SOCKET_PATH` を継承します。エージェントがライフサイクルフックを提供している場合は、そのフックから Herdr の CLI を使ってセマンティックな状態を報告します: - -```bash -"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent \ - --state working -``` - -エージェントが入力待ちになったら `idle`、ユーザーの判断が必要になったら `blocked` を報告します。ブロックの説明には `--message` を使います。エージェントの終了時には、同じ source のライフサイクル権威を解放します: - -```bash -"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent -``` - -Herdr の外では何もしないように、`HERDR_ENV=1` で必要な変数が存在するときだけ報告してください。`--source` はインテグレーションごとに一意かつ固定にします。報告が順不同で届く可能性がある場合は、単調増加する `--seq` を含めてください。Herdr は同じ source から届いた古いシーケンス番号を無視します。 - -`report-agent` に `--agent-session-id` または `--agent-session-path` を含めることも、セッション識別が状態とは独立して変わる場合に `pane report-agent-session` を使うこともできます。Herdr はその参照をペイン API とエージェント API で公開します。セッションの自動復元には、それに加えて、そのエージェントの起動方法と参照先セッションの再開方法を Herdr が把握している必要があります。 - -移植可能なインテグレーションには `HERDR_BIN_PATH` と CLI ラッパーを使ってください。直接 IPC が必要なコードでは、[Socket API](/ja/docs/socket-api/) に記載された同等の `pane.report_agent`、`pane.report_agent_session`、`pane.release_agent` リクエストを送信できます。 - -[Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際の実装例です。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時に権威を解放します。 - 一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Letta Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `2`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 +## 独自エージェントを統合する + +コーディングエージェントを開発している場合は、Herdr にネイティブサポートを追加せずに、自分のコードからエージェントの状態と resume コマンドを Herdr に報告できます。[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)を参照してください。 + ## Pi Pi インテグレーションをインストールします: diff --git a/docs/next/website/src/content/docs/ja/session-state.mdx b/docs/next/website/src/content/docs/ja/session-state.mdx index 4f16bda6..a66d6a92 100644 --- a/docs/next/website/src/content/docs/ja/session-state.mdx +++ b/docs/next/website/src/content/docs/ja/session-state.mdx @@ -83,7 +83,7 @@ pane_history = true resume_agents_on_restore = false ``` -Herdr が resume するのは、現行の公式 Herdr インテグレーションを通じてネイティブセッション参照を報告したペインだけです。 +Herdr が resume するのは、現行の公式 Herdr インテグレーションを通じてネイティブセッション参照を報告したペインと、エージェント自身が resume コマンドを報告したペインです。[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)を参照してください。 クライアントがアタッチしてターミナルサイズとテーマのコンテキストを提供すると、Herdr は各ペインがフォーカスされるのを待たずに、ワークスペースとタブをまたいで復元対象のエージェントペインを resume します。 diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index 35a03625..361f5222 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -545,6 +545,22 @@ Herdr はローカルソケット上の改行区切り JSON を使います。Un } ``` +どちらのメソッドも、Herdr サーバーの再起動後に報告元の現在のセッションを再開するコマンドを、省略可能な `resume_argv` 配列で受け付けます。最初の要素はパスではなく単純なコマンド名でなければならず、どの要素にもアポストロフィを含めることはできません。無効な値は `invalid_resume_argv` で失敗し、その報告は適用されません。カスタムエージェントは、コマンドを付ける前に `pane.report_agent` でペインを保持している必要があり、そうでない場合は `resume_not_accepted` で失敗します。Herdr は同じ `source` と `agent` がペインを保持している間だけコマンドを保持し、そのエージェントに対する Herdr 組み込みの resume コマンドより優先します。古い Herdr はこのフィールドを無視します。 + +```json +{ + "id": "req_3", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "custom:my-agent", + "agent": "my-agent", + "agent_session_id": "...", + "resume_argv": ["my-agent", "--resume", "..."] + } +} +``` + Herdr に保存されたネイティブセッション参照があるとき、`pane.get`、`pane.list`、`agent.get`、`agent.list` は読み取り専用の `agent_session` オブジェクトを公開します: ```json diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index d333dab7..69bff955 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -81,7 +81,7 @@ Native agent session restore is enabled by default. Disable it with: resume_agents_on_restore = false ``` -Herdr resumes panes that reported a native session reference through a current official Herdr integration, and panes whose agent reported its own resume command. See [Integrate your own agent](/docs/integrations/#integrate-your-own-agent). +Herdr resumes panes that reported a native session reference through a current official Herdr integration, and panes whose agent reported its own resume command. See [Add Herdr support to your agent](/docs/add-herdr-support/). After a client attaches and provides terminal size and theme context, Herdr resumes eligible restored agent panes across workspaces and tabs without waiting for each pane to be focused. diff --git a/docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx b/docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx new file mode 100644 index 00000000..827fd233 --- /dev/null +++ b/docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx @@ -0,0 +1,117 @@ +--- +title: 为你的智能体添加 Herdr 支持 +description: 在不为 Herdr 添加原生支持的情况下,从你自己的代码向 Herdr 上报编码智能体的状态和恢复命令。 +--- + +如果你在开发编码智能体,只靠你自己的代码就能让它在 Herdr 中获得一等支持。不需要向 Herdr 提交拉取请求,也不需要等待 Herdr 发布新版本。 + +智能体向 Herdr 上报后,用户会得到: + +- 在侧边栏和 `herdr agent list` 中显示的智能体名称以及 `idle`、`working` 或 `blocked` 状态 +- 智能体完成或需要用户决策时的通知 +- `herdr agent wait` 以及其他等待智能体状态的自动化 +- Herdr 服务器重启后,同一个窗格中恢复同一个会话 + +对于厂商没有提供集成的智能体,由 Herdr 维护集成。如果你维护自己的智能体,自己掌握集成就可以按自己的发布节奏修复问题。 + +## 工作方式 + +Herdr 窗格中的每个进程都会继承这些环境变量: + +| 变量 | 含义 | +| --- | --- | +| `HERDR_ENV` | 在 Herdr 中为 `1` | +| `HERDR_PANE_ID` | 智能体所在的窗格 | +| `HERDR_BIN_PATH` | 管理这个窗格的 Herdr 可执行文件 | +| `HERDR_SOCKET_PATH` | Herdr 的 API 套接字 | + +你的智能体通过 `"$HERDR_BIN_PATH"` 发送三类上报: + +1. **状态。** 每当 `idle`、`working` 或 `blocked` 发生变化时上报。 +2. **恢复命令。** 上报重新打开当前会话的命令。 +3. **释放。** 智能体退出时释放窗格。 + +仅在 `HERDR_ENV=1` 且其他变量都存在时上报。在 Herdr 之外,集成不应执行任何操作。 + +## 上报状态 + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state working \ + --seq 1 +``` + +- 一轮开始时上报 `working`,可以接收输入时上报 `idle`,需要用户做决定时上报 `blocked`。可以用 `--message` 说明阻塞原因。 +- `--agent` 是用户看到的名称。请使用你自己智能体的名称,不要使用 Herdr 已经支持的智能体的名称。 +- `--source` 用于标识你的集成。保持稳定且唯一。不要以 `herdr:` 开头,这个前缀由 Herdr 自己的集成使用。 +- `--seq` 可以省略,但建议加上。它必须随来自你的来源的每次上报递增,跨会话和智能体重启也要继续递增。时间戳就很合适。Herdr 会忽略编号不大于上次已接受编号的上报,所以迟到或乱序的上报不会覆盖更新的状态。 + +## 上报恢复命令 + +把恢复当前会话的命令放在 `--` 之后。带上该会话需要的选项,例如模型或权限模式,这样恢复后的会话行为一致: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state idle \ + --seq 2 \ + --agent-session-id "$SESSION_ID" \ + -- my-agent --resume "$SESSION_ID" --model my-model +``` + +命令可以附在任何一次状态上报中,只有会话变化时也可以用 `pane report-agent-session` 发送。每当智能体切换会话时,请重新上报。 + +Herdr 服务器重启后,Herdr 会在同一目录中打开该窗格并在其中运行这条命令。命令必须遵守以下规则: + +- 第一个词是用户 `PATH` 上的普通命令名,例如 `my-agent`,不能是路径。 +- 任何参数都不能包含撇号。 +- 你的来源必须先持有该窗格,所以请在发送命令之前或同时用 `report-agent` 上报状态。否则 Herdr 会返回 `resume_not_accepted`。 + +违反前两条规则的命令会被 Herdr 以 `invalid_resume_argv` 拒绝,该次上报也不会生效。用户可以用 `[session] resume_agents_on_restore = false` 关闭恢复。 + +## 退出时释放 + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --seq 3 +``` + +释放会立即从窗格中清除智能体的名称、状态和恢复命令。只在用户真正退出时释放。如果智能体在同一个进程中用一个会话替换另一个会话,请上报新会话,而不是释放。 + +如果智能体退出时没有释放,Herdr 会在窗格回到空闲的 shell 提示符后察觉,并清除该智能体及其恢复命令。通常需要一两秒。这只是兜底机制,不能代替释放。 + +## 不要拖慢智能体 + +- 不要让 Herdr 拖慢你的智能体。在后台或用较短的超时发送上报,并忽略失败。 +- 只发送最新状态。如果一次上报正在发送时积累了多个变化,丢弃较旧的那些。 +- 旧版 Herdr 会忽略恢复命令,其他功能照常工作。 + +## 直接使用套接字 + +`HERDR_BIN_PATH` 和 CLI 是可移植的选择,包括在 Windows 上。如果你的智能体需要直接 IPC,可以发送 [Socket API](/zh-cn/docs/socket-api/) 中说明的等效 `pane.report_agent`、`pane.report_agent_session` 和 `pane.release_agent` 请求。恢复命令放在 `resume_argv` 数组中。 + +## 检查你的集成 + +在 Herdr 窗格中启动你的智能体,然后查看 Herdr 看到了什么: + +```bash +herdr pane get "$HERDR_PANE_ID" +herdr agent list +``` + +要在不影响日常会话的情况下测试恢复,请使用单独的命名会话: + +1. 启动 `herdr --session my-agent-test`,然后在一个窗格中运行你的智能体。 +2. 用 `herdr session stop my-agent-test` 停止该会话。 +3. 再次启动 `herdr --session my-agent-test`。窗格应当运行你的恢复命令,智能体应当再次上报。 + +## 示例 + +[Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实的集成。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放窗格。 + +如果想在不改变状态的情况下改变智能体的显示方式,例如标题或自定义状态文字,请参阅[自定义状态标签](/zh-cn/docs/integrations/#自定义状态标签)。 diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx index 80d572c6..13ec4998 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -114,7 +114,7 @@ herdr integration install claude herdr integration status ``` -每个受支持的智能体都有自己的集成名称和行为。按智能体的细节和完整安装列表见[集成](/zh-cn/docs/integrations/)。如果你正在构建智能体,[自定义集成指南](/zh-cn/docs/integrations/#集成你自己的智能体)介绍了如何在不为 Herdr 添加原生支持的情况下上报生命周期状态。 +每个受支持的智能体都有自己的集成名称和行为。按智能体的细节和完整安装列表见[集成](/zh-cn/docs/integrations/)。如果你正在构建智能体,[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)介绍了如何在不为 Herdr 添加原生支持的情况下上报生命周期状态和恢复命令。 ## 自定义智能体标签 diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 987e0c4a..ae5c3ab4 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -258,7 +258,8 @@ herdr pane report-agent <pane_id> \ [--message TEXT] \ [--seq N] \ [--agent-session-id ID] \ - [--agent-session-path PATH] + [--agent-session-path PATH] \ + [-- RESUME_COMMAND...] herdr pane report-agent-session <pane_id> \ --source ID \ @@ -266,7 +267,8 @@ herdr pane report-agent-session <pane_id> \ [--seq N] \ [--agent-session-id ID] \ [--agent-session-path PATH] \ - [--session-start-source SOURCE] + [--session-start-source SOURCE] \ + [-- RESUME_COMMAND...] herdr pane release-agent <pane_id> \ --source ID \ @@ -276,6 +278,8 @@ herdr pane release-agent <pane_id> \ `report-agent-session` 只更新原生会话身份,不上报生命周期状态。`release-agent` 在智能体进程退出时结束该来源的生命周期权威。 +`--` 之后的参数是服务器重启后 Herdr 为恢复此会话而运行的命令。第一个参数必须是 `PATH` 上的普通命令名,并且任何参数都不能包含撇号。 + 当官方集成上报了原生会话引用时,`pane get`、`pane list`、`agent get` 和 `agent list` 会包含一个只读的 `agent_session` 对象。没有存储原生会话引用时,该字段被省略。 当 Herdr 能解析控制窗格的前台进程的 cwd 时,这些命令会包含 `foreground_cwd`。已有的 `cwd` 字段仍然是用于标签和 follow-cwd 行为的窗格/工作区 cwd。 diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index 32d97e08..0fc3d2c0 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -68,37 +68,14 @@ Herdr 以两种不同方式使用集成: 自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。 -## 集成你自己的智能体 - -在 Herdr 窗格中运行的智能体会继承 `HERDR_ENV`、`HERDR_PANE_ID`、`HERDR_BIN_PATH` 和 `HERDR_SOCKET_PATH`。如果智能体提供生命周期钩子,可从这些钩子通过 Herdr CLI 上报语义状态: - -```bash -"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent \ - --state working -``` - -当智能体等待输入时上报 `idle`,需要用户决策时上报 `blocked`。用 `--message` 描述阻塞原因。智能体退出时,释放同一来源的生命周期权威: - -```bash -"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent -``` - -仅在 `HERDR_ENV=1` 且必要变量存在时上报,这样集成在 Herdr 外不会执行任何操作。保持 `--source` 稳定且对该集成唯一。如果上报可能乱序到达,请加入严格递增的 `--seq`;Herdr 会忽略同一来源的旧序列号。 - -你可以在 `report-agent` 中加入 `--agent-session-id` 或 `--agent-session-path`,也可以在会话身份独立于状态变化时使用 `pane report-agent-session`。Herdr 会通过窗格和智能体 API 暴露该引用。自动恢复会话还要求 Herdr 知道如何启动该智能体并恢复所引用的会话。 - -要实现可移植的集成,请使用 `HERDR_BIN_PATH` 和 CLI 包装命令。需要直接 IPC 的代码可以发送 [Socket API](/zh-cn/docs/socket-api/) 中说明的等效 `pane.report_agent`、`pane.report_agent_session` 和 `pane.release_agent` 请求。 - -[Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实实现示例。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放权威。 - 一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Letta Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `2`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 +## 集成你自己的智能体 + +如果你在开发编码智能体,可以在不为 Herdr 添加原生支持的情况下,从你自己的代码向 Herdr 上报它的状态和恢复命令。参阅[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)。 + ## Pi 安装 Pi 集成: diff --git a/docs/next/website/src/content/docs/zh-cn/session-state.mdx b/docs/next/website/src/content/docs/zh-cn/session-state.mdx index 9be84060..6cb83f7d 100644 --- a/docs/next/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/next/website/src/content/docs/zh-cn/session-state.mdx @@ -83,7 +83,7 @@ pane_history = true resume_agents_on_restore = false ``` -Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引用的窗格。 +Herdr 会恢复通过当前官方 Herdr 集成上报了原生会话引用的窗格,以及智能体自己上报了恢复命令的窗格。参阅[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)。 当客户端连接并提供终端尺寸和主题上下文后,Herdr 会跨工作区和标签页恢复符合条件的智能体窗格,不需要等每个窗格被聚焦。 diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index f947fc43..e26c259e 100644 --- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx @@ -541,6 +541,22 @@ Herdr 在本地 socket 上使用换行分隔的 JSON。在 Unix 上,那个 socke } ``` +两个方法都接受可选的 `resume_argv` 数组,内容是 Herdr 服务器重启后恢复上报者当前会话的命令。第一个元素必须是普通命令名而不是路径,并且任何元素都不能包含撇号;无效值会以 `invalid_resume_argv` 失败,该次上报不会生效。自定义智能体必须先通过 `pane.report_agent` 持有窗格才能附加命令,否则请求会以 `resume_not_accepted` 失败。Herdr 只在同一 `source` 和 `agent` 持有窗格时保留该命令,并且它优先于 Herdr 为该智能体内置的恢复命令。旧版 Herdr 会忽略这个字段。 + +```json +{ + "id": "req_3", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "custom:my-agent", + "agent": "my-agent", + "agent_session_id": "...", + "resume_argv": ["my-agent", "--resume", "..."] + } +} +``` + Herdr 存有原生会话引用时,`pane.get`、`pane.list`、`agent.get` 和 `agent.list` 暴露一个只读的 `agent_session` 对象: ```json From 0b9c8ead0073221aaf4f1b0a5e01f0c44a61ad0e Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Sun, 27 Sep 2026 20:46:47 +0200 Subject: [PATCH 79/99] feat: simplify ssh machine setup discovery (#4204) --- .../src/content/docs/cli-reference.mdx | 2 +- .../src/content/docs/connecting-machines.mdx | 8 +- src/cli/machine.rs | 124 +++++++- src/cli/spec/machine.rs | 2 +- src/remote/attach.rs | 269 +++++++++++++----- tests/machine_setup.rs | 233 ++++++++++++++- tests/remote_attach.rs | 40 +-- 7 files changed, 577 insertions(+), 101 deletions(-) diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 1b6f7019..31fe26a9 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -59,7 +59,7 @@ herdr machine enable <profile-id> herdr machine remove <profile-id> ``` -By default, `machine add` uses the remote default session. Add `--remote-session <name>` only for a named session. `machine list` accepts optional `--json` for scripts. See [Connecting machines](/docs/connecting-machines/) for the full setup and connection guide. +In an interactive terminal, `machine add` discovers running sessions and lets you choose when several are running. No running sessions, or no Herdr installation, uses `default`; failed queries against an installed binary require an explicit choice. Add `--remote-session <name>` to skip discovery. Non-interactive commands use `default` unless that flag is supplied. `machine list` accepts optional `--json` for scripts. See [Connecting machines](/docs/connecting-machines/) for the full setup and connection guide. `machine add` checks the remote installation's capabilities, installs or updates with approval only when needed, and starts the requested session's background server before saving. Compatible release versions do not need to match. Run setup in an interactive terminal when installation or restart approval is required; failed or cancelled setup does not save a profile. Replacing a running server requires explicit approval with a default answer of No, and stops its pane processes. `machine add` never enables experimental handoff implicitly. diff --git a/docs/next/website/src/content/docs/connecting-machines.mdx b/docs/next/website/src/content/docs/connecting-machines.mdx index 2ce18b06..47bc19a5 100644 --- a/docs/next/website/src/content/docs/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/connecting-machines.mdx @@ -35,9 +35,11 @@ Run setup in an interactive terminal so Herdr can ask before installing or repla herdr machine add workbox ``` -The sidebar shows the machine as `workbox`, the SSH host without any `user@` prefix. Add `--label "Build machine"` to choose a different name. This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host. +In an interactive terminal, Herdr discovers running sessions: it selects the only running session, or lets you choose with Up/Down and Enter when several are running. Esc or Ctrl+C cancels. If a successful query finds no running sessions, setup uses `default`. If no Herdr installation is found, setup offers to install it for `default`. If an installed binary cannot report its sessions, setup reports the error; pass `--remote-session <name>` to select a session explicitly. -To use a named session instead, add the optional `--remote-session` flag: +The sidebar shows the default session as `workbox`, the SSH host without any `user@` prefix. Add `--label "Build machine"` to choose a different name. A machine profile targets one remote session; it does not combine every session on the host. Non-interactive commands use `default` unless `--remote-session` is supplied. + +To skip discovery and use a specific session, add `--remote-session`: ```bash herdr machine add workbox --remote-session agents @@ -45,6 +47,8 @@ herdr machine add workbox --remote-session agents Without `--label`, this machine shows as `workbox/agents`. If the default name is already taken, `machine add` asks you to pass `--label`. +On Windows servers, setup also checks executables used by Herdr processes owned by the SSH user. It validates their capabilities before reuse, so a compatible running build can be used even when SSH's `PATH` points to an older installation. + Herdr checks both the installed binary and the running server. It starts the requested background server before saving the profile. Compatible client and server versions do not have to match. Missing or incompatible installations go through an approval-based setup. When the running server needs replacement, setup asks before stopping it and its pane processes, then starts the compatible server. The default answer is No. If installation and replacement are both needed, one confirmation covers them. `machine add` does not use experimental live handoff. Cancelling or failing setup leaves the profile unsaved. Run `herdr` to open the UI. If a local client is already open, added and enabled machines normally appear within a second and connect in the background without changing your selection. An in-progress machine switch finishes before profile changes are applied. The remote server keeps running after setup exits. diff --git a/src/cli/machine.rs b/src/cli/machine.rs index 52828f75..2459805a 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -1,3 +1,8 @@ +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, MAX_LABEL_BYTES}; @@ -206,7 +211,7 @@ fn reconnect(args: &[String]) -> std::io::Result<i32> { struct AddArgs { target: String, label: Option<String>, - session: String, + session: Option<String>, } fn parse_add_args(args: &[String]) -> Result<AddArgs, String> { @@ -249,7 +254,6 @@ fn parse_add_args(args: &[String]) -> Result<AddArgs, String> { "usage: herdr machine add <ssh-target> [--label <label>] [--remote-session <name>]" .to_owned() })?; - let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); Ok(AddArgs { target, label, @@ -304,6 +308,27 @@ fn add(args: &[String]) -> std::io::Result<i32> { return Ok(2); } }; + let mut setup = None; + let session = + if session.is_none() && std::io::stdin().is_terminal() && std::io::stderr().is_terminal() { + let discovered = (|| { + let connection = crate::remote::SavedSshSetup::connect(&target)?; + let sessions = connection.running_sessions()?; + let session = select_remote_session(&sessions, &target)?; + setup = Some(connection); + Ok::<_, std::io::Error>(session) + })(); + match discovered { + Ok(session) => session, + Err(error) => { + eprintln!("error: {error}; machine was not saved"); + crate::remote::print_saved_ssh_error_hint(&error, &target); + return Ok(1); + } + } + } else { + session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()) + }; let label_is_default = label.is_none(); let label = label.unwrap_or_else(|| default_label(&target, &session)); let mut catalog = load_catalog()?; @@ -320,7 +345,11 @@ fn add(args: &[String]) -> std::io::Result<i32> { return Ok(2); } } - let metadata = match crate::remote::prepare_saved_ssh(&target, &session) { + let metadata = match setup + .map(Ok) + .unwrap_or_else(|| crate::remote::SavedSshSetup::connect(&target)) + .and_then(|setup| setup.prepare(&session)) + { Ok(metadata) => metadata, Err(error) => { eprintln!("error: {error}; machine was not saved"); @@ -361,6 +390,83 @@ fn add(args: &[String]) -> std::io::Result<i32> { Ok(0) } +fn select_remote_session(sessions: &[String], target: &str) -> std::io::Result<String> { + match sessions { + [] => return Ok(crate::session::DEFAULT_SESSION_NAME.to_owned()), + [session] => return Ok(session.clone()), + _ => {} + } + + let _raw_mode = RawModeGuard::enable()?; + let mut output = std::io::stderr(); + let mut selected = 0; + render_remote_session_picker(&mut output, target, sessions, selected, false)?; + loop { + let Event::Key(key) = crossterm::event::read()? else { + continue; + }; + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + continue; + } + match key.code { + KeyCode::Up => selected = selected.checked_sub(1).unwrap_or(sessions.len() - 1), + KeyCode::Down => selected = (selected + 1) % sessions.len(), + KeyCode::Enter => return Ok(sessions[selected].clone()), + KeyCode::Esc => break, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break, + _ => continue, + } + render_remote_session_picker(&mut output, target, sessions, selected, true)?; + } + Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "remote session selection cancelled", + )) +} + +struct RawModeGuard; + +impl RawModeGuard { + fn enable() -> std::io::Result<Self> { + terminal::enable_raw_mode()?; + Ok(Self) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + } +} + +fn render_remote_session_picker( + output: &mut impl std::io::Write, + target: &str, + sessions: &[String], + selected: usize, + redraw: bool, +) -> std::io::Result<()> { + if redraw { + execute!(output, cursor::MoveUp((sessions.len() + 2) as u16))?; + } + execute!(output, terminal::Clear(terminal::ClearType::CurrentLine))?; + write!(output, "Running sessions on {target}:\r\n")?; + for (index, session) in sessions.iter().enumerate() { + execute!(output, terminal::Clear(terminal::ClearType::CurrentLine))?; + if index == selected { + execute!(output, SetAttribute(Attribute::Bold))?; + write!(output, "> {session}")?; + execute!(output, SetAttribute(Attribute::Reset))?; + write!(output, "\r\n")?; + } else { + write!(output, " {session}\r\n")?; + } + } + execute!(output, terminal::Clear(terminal::ClearType::CurrentLine))?; + write!(output, "↑/↓ select · Enter confirm · Esc cancel\r\n")?; + output.flush() +} + fn rename(args: &[String]) -> std::io::Result<i32> { let args = super::expand_equals_args(args, &["--label"]); let [raw_id, flag, label] = args.as_slice() else { @@ -480,8 +586,8 @@ mod tests { #[test] fn add_parser_preserves_values_across_argument_orders() { for (args, session) in [ - (vec!["--label", "coder", "workstation.coder"], "default"), - (vec!["workstation.coder", "--label", "coder"], "default"), + (vec!["--label", "coder", "workstation.coder"], None), + (vec!["workstation.coder", "--label", "coder"], None), ( vec![ "--remote-session", @@ -490,7 +596,7 @@ mod tests { "--label", "coder", ], - "agents", + Some("agents"), ), ( vec![ @@ -498,7 +604,7 @@ mod tests { "--remote-session=agents", "workstation.coder", ], - "agents", + Some("agents"), ), ] { let args = args.into_iter().map(str::to_owned).collect::<Vec<_>>(); @@ -507,7 +613,7 @@ mod tests { AddArgs { target: "workstation.coder".into(), label: Some("coder".into()), - session: session.into(), + session: session.map(str::to_owned), }, "{args:?}" ); @@ -518,7 +624,7 @@ mod tests { fn add_parser_leaves_label_unset_without_flag() { let parsed = parse_add_args(&["workstation.coder".to_owned()]).unwrap(); assert_eq!(parsed.label, None); - assert_eq!(parsed.session, "default"); + assert_eq!(parsed.session, None); } #[test] diff --git a/src/cli/spec/machine.rs b/src/cli/spec/machine.rs index 19a34088..dc4eefa5 100644 --- a/src/cli/spec/machine.rs +++ b/src/cli/spec/machine.rs @@ -36,7 +36,7 @@ pub(super) fn command() -> Command { ) .arg( option("remote-session", "NAME") - .help("Set the explicit Herdr session on the remote machine"), + .help("Select a session explicitly (default without an interactive terminal)"), ), ) .subcommand( diff --git a/src/remote/attach.rs b/src/remote/attach.rs index a4a00dea..82caeb9a 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -119,72 +119,145 @@ pub(crate) fn check_saved_ssh(target: &str, session: &str) -> io::Result<()> { } } -pub(crate) fn prepare_saved_ssh( - target: &str, - session_name: &str, -) -> io::Result<Option<crate::client::endpoint::SshMachineMetadata>> { - super::validate_remote_target(target) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - crate::session::validate_name(session_name) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let manage_ssh_config = crate::config::Config::load() - .config - .remote - .manage_ssh_config; - let ssh = RemoteSsh::new( - target.to_owned(), - manage_ssh_config, - session_name.to_owned(), - ); - let prepared = prepare_remote_herdr(&ssh, false, true)?; - ensure_remote_server_ready( - &ssh, - &prepared.remote_herdr, - prepared.stop_after_install_approved, - false, - true, - )?; +pub(crate) struct SavedSshSetup { + ssh: RemoteSsh, + remote_herdr: RemoteHerdr, + candidates: Vec<RemoteHerdr>, +} + +impl SavedSshSetup { + pub(crate) fn connect(target: &str) -> io::Result<Self> { + super::validate_remote_target(target) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let manage = crate::config::Config::load() + .config + .remote + .manage_ssh_config; + let ssh = RemoteSsh::new( + target.to_owned(), + manage, + crate::session::DEFAULT_SESSION_NAME.to_owned(), + ); + let remote_herdr = RemoteHerdr::for_platform(detect_remote_platform(&ssh)?); + let candidates = remote_binary_candidates(&ssh, &remote_herdr)?; + Ok(Self { + ssh, + remote_herdr, + candidates, + }) + } - // The bridge already owns daemon startup. EOF closes only this temporary attachment, - // leaving the named server running even when no local TUI is open yet. - let command = prepared - .remote_herdr - .executable - .saved_bridge_command(session_name); - let output = ssh.shell_output(&prepared.remote_herdr.platform, &command)?; - if !output.status.success() { - return Err(command_failed("remote server startup failed", &output)); + pub(crate) fn running_sessions(&self) -> io::Result<Vec<String>> { + // A fresh host must still reach the normal installation approval. + if self.candidates.is_empty() { + return Ok(Vec::new()); + } + let mut failure = String::new(); + for candidate in &self.candidates { + let output = self.ssh.shell_output( + &candidate.platform, + &candidate.executable.command(&["session", "list", "--json"]), + )?; + if output.status.code() == Some(255) { + return Err(command_failed("remote SSH connection failed", &output)); + } + if !output.status.success() { + failure = command_failed("remote session query failed", &output).to_string(); + continue; + } + match serde_json::from_slice::<RemoteSessionListJson>(&output.stdout) { + Ok(list) => { + return Ok(list + .sessions + .into_iter() + .filter(|session| { + session.running && crate::session::validate_name(&session.name).is_ok() + }) + .map(|session| session.name) + .collect()) + } + Err(error) => failure = format!("invalid remote session list: {error}"), + } + } + Err(io::Error::other(format!( + "could not discover remote Herdr sessions: {failure}; specify --remote-session to continue" + ))) } - match remote_server_status(&ssh, &prepared.remote_herdr, true)? { - RemoteServerStatus::Running { - endpoint_protocol_generation, - surface_interest, - health_check, - detached_server_daemon, - .. - } if remote_server_restart_reason( - endpoint_protocol_generation, - detached_server_daemon, + + pub(crate) fn prepare( + mut self, + session_name: &str, + ) -> io::Result<Option<crate::client::endpoint::SshMachineMetadata>> { + crate::session::validate_name(session_name) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + self.ssh.session_name = session_name.to_owned(); + let Self { + ssh, + remote_herdr, + candidates, + } = self; + let prepared = + prepare_discovered_remote_herdr(&ssh, remote_herdr, &candidates, false, true)?; + ensure_remote_server_ready( + &ssh, + &prepared.remote_herdr, + prepared.stop_after_install_approved, + false, true, - surface_interest, - health_check, - ) - .is_none() => - { - Ok(prepared.remote_herdr.machine_metadata().or_else(|| { - discover_remote_api_metadata(&ssh, session_name) - .inspect_err( - |error| tracing::debug!(%error, "could not capture SSH setup metadata"), - ) - .ok() - })) + )?; + + // The bridge already owns daemon startup. EOF closes only this temporary attachment, + // leaving the named server running even when no local TUI is open yet. + let command = prepared + .remote_herdr + .executable + .saved_bridge_command(session_name); + let output = ssh.shell_output(&prepared.remote_herdr.platform, &command)?; + if !output.status.success() { + return Err(command_failed("remote server startup failed", &output)); + } + match remote_server_status(&ssh, &prepared.remote_herdr, true)? { + RemoteServerStatus::Running { + endpoint_protocol_generation, + surface_interest, + health_check, + detached_server_daemon, + .. + } if remote_server_restart_reason( + endpoint_protocol_generation, + detached_server_daemon, + true, + surface_interest, + health_check, + ) + .is_none() => + { + Ok(prepared.remote_herdr.machine_metadata().or_else(|| { + discover_remote_api_metadata(&ssh, session_name) + .inspect_err( + |error| tracing::debug!(%error, "could not capture SSH setup metadata"), + ) + .ok() + })) + } + _ => Err(io::Error::other( + "remote server is not ready for saved machines", + )), } - _ => Err(io::Error::other( - "remote server is not ready for saved machines", - )), } } +#[derive(Deserialize)] +struct RemoteSessionListJson { + sessions: Vec<RemoteSessionJson>, +} + +#[derive(Deserialize)] +struct RemoteSessionJson { + name: String, + running: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RemotePlatform { os: &'static str, @@ -1201,19 +1274,36 @@ pub(super) fn prepare_remote_herdr( ) -> io::Result<PreparedRemoteHerdr> { let platform = detect_remote_platform(ssh)?; let remote_herdr = RemoteHerdr::for_platform(platform); + let candidates = remote_binary_candidates(ssh, &remote_herdr)?; + prepare_discovered_remote_herdr( + ssh, + remote_herdr, + &candidates, + live_handoff_enabled, + require_surface_interest, + ) +} + +fn prepare_discovered_remote_herdr( + ssh: &RemoteSsh, + remote_herdr: RemoteHerdr, + candidates: &[RemoteHerdr], + live_handoff_enabled: bool, + require_surface_interest: bool, +) -> io::Result<PreparedRemoteHerdr> { if remote_herdr.platform.is_windows() { return prepare_windows_remote_herdr( ssh, remote_herdr, + candidates, live_handoff_enabled, require_surface_interest, ); } let override_binary = remote_binary_override_path()?; - let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?; if override_binary.is_none() { - for candidate in &remote_binary_candidates { + for candidate in candidates { if remote_binary_supports_endpoint_requirement(ssh, candidate, require_surface_interest) .unwrap_or(false) { @@ -1236,7 +1326,7 @@ pub(super) fn prepare_remote_herdr( } let mut stop_after_install_approved = false; - if let Some(status_probe_herdr) = remote_binary_candidates.first().or_else(|| { + if let Some(status_probe_herdr) = candidates.first().or_else(|| { remote_binary_exists(ssh, &remote_herdr) .ok() .and_then(|exists| exists.then_some(&remote_herdr)) @@ -1299,14 +1389,14 @@ pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteH fn prepare_windows_remote_herdr( ssh: &RemoteSsh, remote_herdr: RemoteHerdr, + candidates: &[RemoteHerdr], live_handoff_enabled: bool, require_surface_interest: bool, ) -> io::Result<PreparedRemoteHerdr> { let override_package = remote_binary_override_path()?; let custom_package = override_package.is_some(); - let candidates = remote_binary_candidates(ssh, &remote_herdr)?; if !custom_package { - for candidate in &candidates { + for candidate in candidates { if remote_binary_supports_endpoint_requirement( ssh, candidate, @@ -1524,7 +1614,7 @@ fn remote_binary_candidates( fn windows_remote_binary_candidate_command() -> String { windows_powershell_script_command(&format!( - r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"# + r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; Get-CimInstance Win32_Process -Filter "Name = 'herdr.exe' OR Name = 'herdr-dev.exe'" -ErrorAction SilentlyContinue | ForEach-Object {{ $owner = Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid -ErrorAction SilentlyContinue; if ($owner.ReturnValue -eq 0 -and $owner.Sid -eq $userSid) {{ Emit-HerdrPath $_.ExecutablePath }} }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"# )) } @@ -4614,6 +4704,55 @@ mod tests { assert!(command.contains("$activeJunction.Target")); } + #[cfg(windows)] + #[test] + fn windows_process_candidates_require_a_verified_current_user_owner() { + // Supply ownership results without needing another Windows account. + // Execute the real discovery script, including its path validation. + let fixture = r#" +function Get-Command {} +function Get-Item {} +function Get-CimInstance { + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + [pscustomobject]@{ ExecutablePath=$env:HERDR_TEST_EXE; OwnerSid=$sid; OwnerResult=0 } + [pscustomobject]@{ ExecutablePath="$env:SystemRoot\System32\cmd.exe"; OwnerSid='S-1-0-0'; OwnerResult=0 } + [pscustomobject]@{ ExecutablePath="$PSHOME\powershell.exe"; OwnerSid=$sid; OwnerResult=2 } +} +function Invoke-CimMethod { + param($InputObject) + [pscustomobject]@{ ReturnValue=$InputObject.OwnerResult; Sid=$InputObject.OwnerSid } +} +function Get-Process { + Get-CimInstance | ForEach-Object { [pscustomobject]@{ Path=$_.ExecutablePath } } +} +"#; + let executable = std::env::current_exe().unwrap(); + let script = format!( + "{fixture}\n{}", + decode_windows_command(&windows_remote_binary_candidate_command()) + ); + let encoded = windows_powershell_script_command(&script); + let mut command = Command::new("powershell.exe"); + command + .args(["-NoProfile", "-NonInteractive", "-EncodedCommand"]) + .arg(encoded.split_whitespace().last().unwrap()) + .env("HERDR_TEST_EXE", &executable); + crate::platform::configure_background_command(&mut command); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let paths = String::from_utf8(output.stdout) + .unwrap() + .lines() + .filter_map(|line| line.strip_prefix(WINDOWS_REMOTE_PATH_MARKER)) + .map(|path| decode_windows_remote_path(path).unwrap()) + .collect::<Vec<_>>(); + assert_eq!(paths, [executable.to_string_lossy().into_owned()]); + } + #[test] fn remote_output_framing_discards_any_banner_and_preserves_binary() { let payload = [0, 1, 2, 0xff, b'\n']; diff --git a/tests/machine_setup.rs b/tests/machine_setup.rs index 3b577f4e..649ba7c0 100644 --- a/tests/machine_setup.rs +++ b/tests/machine_setup.rs @@ -21,7 +21,7 @@ if [ "$FAKE_STRICT_HOST_KEY_FAILURE" = yes ] && [ "$strict_host_key_check" = yes exit 255 fi if [ "$last" = 'command -v herdr' ]; then - echo /home/remote/.local/bin/herdr + if [ "$FAKE_INSTALLED" != missing ]; then echo /home/remote/.local/bin/herdr; fi exit 0 fi case "$last" in @@ -33,8 +33,10 @@ case "$last" in esac printf '\n%s\n' 'herdr-remote-output-ready:1' case "$script" in - *'uname -s'*) uname -s; uname -m ;; - *'version='*) echo /home/remote/.local/bin/herdr ;; + *'uname -s'*) echo platform >>"$FAKE_ROOT/probes"; uname -s; uname -m ;; + *'version='*) + echo candidates >>"$FAKE_ROOT/probes" + if [ "$FAKE_INSTALLED" != missing ]; then echo /home/remote/.local/bin/herdr; fi ;; *'status client --json'*) if [ "$FAKE_INSTALLED" = new ] || [ -f "$FAKE_ROOT/installed" ]; then printf '%s\n' "$FAKE_CLIENT_STATUS" @@ -51,10 +53,17 @@ case "$script" in fi ;; *'server live-handoff'*) echo handoff >>"$FAKE_ROOT/actions"; exit 1 ;; *'server stop'*) echo stop >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/stopped" ;; - *'remote-client-bridge'*) echo start >>"$FAKE_ROOT/actions"; echo 'test startup failure' >&2; exit 1 ;; + *'session list --json'*) + echo sessions >>"$FAKE_ROOT/probes" + if [ "$FAKE_SESSIONS" = failed ]; then echo 'session query denied' >&2; exit 1; fi + printf '%s\n' "$FAKE_SESSIONS" ;; + *'remote-client-bridge'*) + printf '%s\n' "$script" >"$FAKE_ROOT/start-command" + echo start >>"$FAKE_ROOT/actions"; echo 'test startup failure' >&2; exit 1 ;; *'mkdir -p'*) printf '/fake/tmp\000/fake/herdr\000' ;; *'chmod 755'*) echo install >>"$FAKE_ROOT/actions"; touch "$FAKE_ROOT/installed" ;; - *'command -v herdr'*) echo /home/remote/.local/bin/herdr ;; + *'command -v herdr'*) if [ "$FAKE_INSTALLED" != missing ]; then echo /home/remote/.local/bin/herdr; fi ;; + *'test -x '*) exit 1 ;; *) echo "unexpected fake SSH script: $script" >&2; exit 1 ;; esac "#; @@ -64,6 +73,16 @@ struct SetupResult { actions: String, prompts: usize, success: bool, + probes: String, + start_command: String, +} + +#[derive(Default)] +struct SetupOptions<'a> { + sessions: Option<&'a str>, + selection: &'a [u8], + explicit_session: Option<&'a str>, + noninteractive: bool, } fn setup(installed: &str, answer: &str, handoff: bool) -> SetupResult { @@ -75,14 +94,34 @@ fn setup_with_strict_host_key_failure( answer: &str, handoff: bool, strict_host_key_failure: bool, +) -> SetupResult { + setup_options( + installed, + answer, + handoff, + strict_host_key_failure, + SetupOptions::default(), + ) +} + +fn setup_options( + installed: &str, + answer: &str, + handoff: bool, + strict_host_key_failure: bool, + options: SetupOptions<'_>, ) -> SetupResult { let root = std::path::PathBuf::from(format!( - "/var/tmp/herdr-machine-setup-{}-{}-{}-{}-{}", + "/var/tmp/herdr-machine-setup-{}-{}-{}-{}-{}-{}", std::process::id(), installed, answer.trim().is_empty(), handoff, - strict_host_key_failure + strict_host_key_failure, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )); let app = if cfg!(debug_assertions) { "herdr-dev" @@ -110,6 +149,9 @@ fn setup_with_strict_host_key_failure( command.args(["--remote", "fake-host", "--handoff"]); } else { command.args(["machine", "add", "fake-host", "--label", "VPS"]); + if let Some(session) = options.explicit_session { + command.args(["--remote-session", session]); + } } command.env( "PATH", @@ -121,6 +163,12 @@ fn setup_with_strict_host_key_failure( command.env("XDG_RUNTIME_DIR", &root); command.env("FAKE_ROOT", &root); command.env("FAKE_INSTALLED", installed); + command.env( + "FAKE_SESSIONS", + options + .sessions + .unwrap_or(r#"{"sessions":[{"name":"default","running":true}]}"#), + ); command.env( "FAKE_STRICT_HOST_KEY_FAILURE", if strict_host_key_failure { "yes" } else { "no" }, @@ -139,6 +187,18 @@ fn setup_with_strict_host_key_failure( ] { command.env_remove(name); } + if options.noninteractive { + // A redirected stdin makes machine add non-interactive while retaining + // the same isolated SSH fixture and captured terminal diagnostics. + let mut shell = CommandBuilder::new("/bin/sh"); + shell.args(["-c", "exec \"$@\" </dev/null", "sh"]); + shell.args(command.get_argv()); + shell.env_clear(); + for (key, value) in command.iter_full_env_as_str() { + shell.env(key, value); + } + command = shell; + } let mut child = pair.slave.spawn_command(command).unwrap(); drop(pair.slave); let mut reader = pair.master.try_clone_reader().unwrap(); @@ -155,10 +215,16 @@ fn setup_with_strict_host_key_failure( let mut output = String::new(); let mut prompts = 0; let mut timed_out = false; + let mut picked = false; loop { match rx.recv_timeout(Duration::from_secs(20)) { Ok(bytes) => { output.push_str(&String::from_utf8_lossy(&bytes)); + if !picked && output.contains("Esc cancel") { + writer.write_all(options.selection).unwrap(); + writer.flush().unwrap(); + picked = true; + } let count = output.matches("[y/N]").count() + output.matches("[Y/n]").count(); if count > prompts { writer @@ -185,6 +251,8 @@ fn setup_with_strict_host_key_failure( drop(pair.master); reading.join().unwrap(); let actions = fs::read_to_string(root.join("actions")).unwrap_or_default(); + let probes = fs::read_to_string(root.join("probes")).unwrap_or_default(); + let start_command = fs::read_to_string(root.join("start-command")).unwrap_or_default(); // Every scenario either cancels or reaches the intentionally failed startup. let saved = root .join("state") @@ -202,6 +270,8 @@ fn setup_with_strict_host_key_failure( actions, prompts, success: status.success(), + probes, + start_command, } } @@ -258,6 +328,155 @@ fn machine_add_accepts_help_argument_order() { assert!(!saved, "failed preparation must not save a machine"); } +#[test] +fn machine_add_fresh_host_offers_installation_and_cancellation_saves_nothing() { + let result = setup("missing", "n\n", false); + assert_eq!(result.probes, "platform\ncandidates\n", "{}", result.output); + assert_eq!(result.prompts, 1, "{}", result.output); + assert!( + result.output.contains("installation cancelled"), + "{}", + result.output + ); + assert!(result.actions.is_empty(), "{}", result.output); +} + +#[test] +fn machine_add_discovers_sessions_once_and_prepares_the_selected_session() { + for (sessions, selection, expected) in [ + (r#"{"sessions":[]}"#, &b""[..], "default"), + ( + r#"{"extra":1,"sessions":[{"name":"stopped","running":false},{"name":"bad/name","running":true},{"name":"agents","running":true,"future":true}]}"#, + &b""[..], + "agents", + ), + ( + r#"{"sessions":[{"name":"default","running":true},{"name":"agents","running":true}]}"#, + &b"\x1b[B\r"[..], + "agents", + ), + ] { + let result = setup_options( + "new", + "y\n", + false, + false, + SetupOptions { + sessions: Some(sessions), + selection, + ..Default::default() + }, + ); + assert_eq!( + result.probes, "platform\ncandidates\nsessions\n", + "{}", + result.output + ); + assert_eq!(result.actions, "stop\nstart\n", "{}", result.output); + if expected == "default" { + assert!( + !result.start_command.contains("--session"), + "{}", + result.start_command + ); + } else { + assert!( + result.start_command.contains("--session agents"), + "{}", + result.start_command + ); + } + if !selection.is_empty() { + let picker = result + .output + .split("Running sessions on fake-host:") + .nth(1) + .unwrap(); + assert!( + picker.starts_with("\r\n"), + "picker did not return to column zero: {picker:?}" + ); + assert!(picker.contains("default\u{1b}[0m\r\n"), "{picker:?}"); + assert!(picker.contains(" agents\r\n"), "{picker:?}"); + } + } +} + +#[test] +fn machine_add_failed_discovery_and_cancel_never_prepare_or_save() { + for (sessions, selection, expected) in [ + ("failed", &b""[..], "session query denied"), + ("not json", &b""[..], "--remote-session"), + ( + r#"{"sessions":[{"name":"agents"}]}"#, + &b""[..], + "--remote-session", + ), + ( + r#"{"sessions":[{"name":"default","running":true},{"name":"agents","running":true}]}"#, + &b"\x1b"[..], + "selection cancelled", + ), + ] { + let result = setup_options( + "new", + "y\n", + false, + false, + SetupOptions { + sessions: Some(sessions), + selection, + ..Default::default() + }, + ); + assert!(!result.success, "{}", result.output); + assert!(result.actions.is_empty(), "{}", result.output); + assert!(result.output.contains(expected), "{}", result.output); + assert!( + result.output.contains("machine was not saved"), + "{}", + result.output + ); + } +} + +#[test] +fn machine_add_skips_discovery_for_scripts_and_explicit_sessions() { + for (noninteractive, explicit_session) in [ + (true, None), + (true, Some("agents")), + (false, Some("agents")), + ] { + let result = setup_options( + "new", + "y\n", + false, + false, + SetupOptions { + noninteractive, + explicit_session, + sessions: Some("failed"), + ..Default::default() + }, + ); + assert_eq!(result.probes, "platform\ncandidates\n", "{}", result.output); + assert!( + result.output.contains(if explicit_session.is_some() { + "session agents" + } else { + "session default" + }), + "{}", + result.output + ); + if noninteractive { + assert!(result.actions.is_empty(), "{}", result.output); + } else { + assert_eq!(result.actions, "stop\nstart\n", "{}", result.output); + } + } +} + #[test] fn machine_add_reports_strict_host_key_failure() { let result = setup_with_strict_host_key_failure("new", "", false, true); diff --git a/tests/remote_attach.rs b/tests/remote_attach.rs index b99e4335..2af61c77 100644 --- a/tests/remote_attach.rs +++ b/tests/remote_attach.rs @@ -1,7 +1,7 @@ #![cfg(unix)] use std::fs; -use std::io::{BufRead, BufReader}; +use std::io::Read; use std::os::unix::fs::PermissionsExt; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; @@ -77,7 +77,7 @@ fn check_authentication_output(framed_shell: bool) { r#"#!/bin/sh authenticate() {{ : > "$FAKE_SSH_STARTED" - printf '%s\n%s\n' '{CHECK_NOTICE}' '{CHECK_URL}' >&2 + printf '/bin/sh: expected probe noise\n%s\n%s' '{CHECK_NOTICE}' '{CHECK_URL}' >&2 while [ ! -e "$FAKE_SSH_APPROVED" ]; do /bin/sleep 0.01 done @@ -94,7 +94,7 @@ if [ "$FAKE_SSH_FRAMED" = 1 ] && [ ! -e "$FAKE_SSH_STARTED" ]; then fi /bin/cat >/dev/null : > "$FAKE_SSH_ADVANCED" -printf '%s\n' '{LATER_FAILURE}' >&2 +printf '\n%s\n' '{LATER_FAILURE}' >&2 exit 255 "# ), @@ -128,22 +128,25 @@ exit 255 cleanup.child = Some(child); let child = cleanup.child.as_mut().expect("registered child"); - let stderr = child.stderr.take().expect("remote attach stderr"); + let mut stderr = child.stderr.take().expect("remote attach stderr"); let (line_tx, line_rx) = mpsc::channel(); cleanup.reader = Some(thread::spawn(move || { - for line in BufReader::new(stderr).lines() { - if line_tx - .send(line.expect("read remote attach stderr")) - .is_err() - { + let mut buffer = [0; 4096]; + while let Ok(len) = stderr.read(&mut buffer) { + if len == 0 || line_tx.send(buffer[..len].to_vec()).is_err() { break; } } })); wait_for_file(&started_path, Duration::from_secs(2)); - let notice = line_rx.recv_timeout(Duration::from_secs(2)); - let url = line_rx.recv_timeout(Duration::from_secs(2)); + let mut before_approval = Vec::new(); + while !String::from_utf8_lossy(&before_approval).contains(CHECK_URL) { + match line_rx.recv_timeout(Duration::from_secs(2)) { + Ok(bytes) => before_approval.extend(bytes), + Err(_) => break, + } + } fs::write(&approval_path, b"approved").expect("release fake ssh approval"); wait_for_file(&advanced_path, Duration::from_secs(2)); @@ -155,16 +158,21 @@ exit 255 .expect("registered stderr reader") .join() .expect("join stderr reader"); - let later_lines = line_rx.try_iter().collect::<Vec<_>>(); + let later_bytes = line_rx.try_iter().flatten().collect::<Vec<_>>(); + let later_lines = String::from_utf8_lossy(&later_bytes); - assert_eq!(notice.as_deref(), Ok(CHECK_NOTICE)); - assert_eq!(url.as_deref(), Ok(CHECK_URL)); + let before_approval = String::from_utf8_lossy(&before_approval); + assert!(before_approval.contains(CHECK_NOTICE), "{before_approval}"); + assert!( + before_approval.contains(CHECK_URL), + "partial prompt was hidden: {before_approval}" + ); assert!( - later_lines.iter().any(|line| line == LATER_FAILURE), + later_lines.contains(LATER_FAILURE), "later SSH stderr should also be visible: {later_lines:?}" ); assert!( - later_lines.iter().any(|line| { + later_lines.lines().any(|line| { line.contains("error: remote binary discovery failed") && line.contains(LATER_FAILURE) }), "failed SSH stderr should remain in the contextual error: {later_lines:?}" From 7945e91e30b91a379fcbbd68cf2100fe122205cd Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Mon, 28 Sep 2026 03:34:03 +0800 Subject: [PATCH 80/99] perf: use keyed terminal lookup for named targets (#4669) --- justfile | 4 + src/app/terminal_targets.rs | 146 ++++++++++++++++++++++++++++++++++-- src/terminal/id.rs | 27 +++++++ 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/justfile b/justfile index 38a45052..e3197c81 100644 --- a/justfile +++ b/justfile @@ -87,6 +87,10 @@ 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 + # ~3-5 minute CPU comparison; downloads stable unless HERDR_PERF_BASELINE_BIN is set bench-release-smoke: cargo build --release --locked 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::<Vec<_>>(), + targets + .iter() + .map(|target| target.terminal_id.as_str()) + .collect::<Vec<_>>(), + ); + } + } + + #[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/terminal/id.rs b/src/terminal/id.rs index c7310753..29a300a5 100644 --- a/src/terminal/id.rs +++ b/src/terminal/id.rs @@ -1,3 +1,4 @@ +use std::borrow::Borrow; use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -31,3 +32,29 @@ impl fmt::Display for TerminalId { f.write_str(&self.0) } } + +impl Borrow<str> for TerminalId { + fn borrow(&self) -> &str { + self.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn terminal_ids_support_borrowed_lookup_without_changing_identity() { + let id = TerminalId::alloc(); + let serialized = serde_json::to_string(&id).unwrap(); + let restored: TerminalId = serde_json::from_str(&serialized).unwrap(); + let mut terminals = HashMap::from([(id.clone(), 1)]); + + assert_eq!(terminals.get(restored.as_str()), Some(&1)); + assert_eq!(terminals.get("missing-terminal"), None); + *terminals.get_mut(restored.as_str()).unwrap() = 2; + assert_eq!(terminals.remove(id.as_str()), Some(2)); + assert!(terminals.is_empty()); + } +} From 6502492fbaa0ac0e5883208a20e3d99584319611 Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Mon, 28 Sep 2026 03:48:44 +0800 Subject: [PATCH 81/99] perf: avoid rebuilding bsp subtrees when splitting panes (#4670) --- justfile | 4 + src/layout.rs | 215 +++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 175 insertions(+), 44 deletions(-) diff --git a/justfile b/justfile index e3197c81..ebe6c571 100644 --- a/justfile +++ b/justfile @@ -91,6 +91,10 @@ bench-render-scale: 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 + # ~3-5 minute CPU comparison; downloads stable unless HERDR_PERF_BASELINE_BIN is set bench-release-smoke: cargo build --release --locked diff --git a/src/layout.rs b/src/layout.rs index c46a0753..5ce72250 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -132,7 +132,7 @@ impl TileLayout { /// Collect all split boundaries for mouse drag resize. pub fn splits(&self, area: Rect) -> Vec<SplitBorder> { let mut result = Vec::new(); - collect_splits(&self.root, area, vec![], &mut result); + collect_splits(&self.root, area, &mut Vec::new(), &mut result); result } @@ -162,13 +162,9 @@ impl TileLayout { direction: Direction, ratio: f32, ) -> Option<PaneId> { - if !self.pane_ids().contains(&target) { - return None; - } + let node = find_pane_mut(&mut self.root, target)?; let new_id = PaneId::alloc(); - let placeholder = PaneId::from_raw(0); - let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); - self.root = split_at(old, target, direction, new_id, valid_split_ratio(ratio)); + *node = split_node(target, direction, new_id, valid_split_ratio(ratio)); Some(new_id) } @@ -187,13 +183,13 @@ impl TileLayout { return false; } let ids = self.pane_ids(); - if !ids.contains(&target) || ids.contains(&moved) { + if ids.contains(&moved) { return false; } - - let placeholder = PaneId::from_raw(0); - let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); - self.root = split_at(old, target, direction, moved, valid_split_ratio(ratio)); + let Some(node) = find_pane_mut(&mut self.root, target) else { + return false; + }; + *node = split_node(target, direction, moved, valid_split_ratio(ratio)); if focus { self.set_focus(moved); } @@ -506,7 +502,7 @@ fn collect_panes(node: &Node, area: Rect, focus: PaneId, result: &mut Vec<PaneIn } } -fn collect_splits(node: &Node, area: Rect, path: Vec<bool>, result: &mut Vec<SplitBorder>) { +fn collect_splits(node: &Node, area: Rect, path: &mut Vec<bool>, result: &mut Vec<SplitBorder>) { if let Node::Split { direction, ratio, @@ -526,12 +522,12 @@ fn collect_splits(node: &Node, area: Rect, path: Vec<bool>, result: &mut Vec<Spl area, path: path.clone(), }); - let mut lp = path.clone(); - lp.push(false); - collect_splits(first, a, lp, result); - let mut rp = path; - rp.push(true); - collect_splits(second, b, rp, result); + path.push(false); + collect_splits(first, a, path, result); + path.pop(); + path.push(true); + collect_splits(second, b, path, result); + path.pop(); } } @@ -587,32 +583,22 @@ fn swap_pane_ids(node: &mut Node, first: PaneId, second: PaneId) { } } -fn split_at( - node: Node, - target: PaneId, - direction: Direction, - new_id: PaneId, - split_ratio: f32, -) -> Node { +fn find_pane_mut(node: &mut Node, target: PaneId) -> Option<&mut Node> { match node { - Node::Pane(id) if id == target => Node::Split { - direction, - ratio: split_ratio, - first: Box::new(Node::Pane(id)), - second: Box::new(Node::Pane(new_id)), - }, - Node::Pane(_) => node, - Node::Split { - direction: d, - ratio, - first, - second, - } => Node::Split { - direction: d, - ratio, - first: Box::new(split_at(*first, target, direction, new_id, split_ratio)), - second: Box::new(split_at(*second, target, direction, new_id, split_ratio)), - }, + Node::Pane(id) if *id == target => Some(node), + Node::Pane(_) => None, + Node::Split { first, second, .. } => { + find_pane_mut(first, target).or_else(|| find_pane_mut(second, target)) + } + } +} + +fn split_node(target: PaneId, direction: Direction, new_id: PaneId, split_ratio: f32) -> Node { + Node::Split { + direction, + ratio: split_ratio, + first: Box::new(Node::Pane(target)), + second: Box::new(Node::Pane(new_id)), } } @@ -713,6 +699,147 @@ fn split_rect(area: Rect, direction: Direction, ratio: f32) -> (Rect, Rect) { mod tests { use super::*; + #[test] + fn split_paths_preserve_preorder_geometry_and_resize_targets() { + let ids = [ + PaneId::alloc(), + PaneId::alloc(), + PaneId::alloc(), + PaneId::alloc(), + ]; + let mut layout = TileLayout::from_saved( + Node::Split { + direction: Direction::Horizontal, + ratio: 0.5, + first: Box::new(split_node(ids[0], Direction::Vertical, ids[1], 0.5)), + second: Box::new(split_node(ids[2], Direction::Vertical, ids[3], 0.25)), + }, + ids[0], + ); + let area = Rect::new(3, 7, 120, 80); + let splits = layout.splits(area); + assert_eq!(splits.len(), 3); + for (split, (path, direction, pos, rect)) in splits.iter().zip([ + (vec![], Direction::Horizontal, 63, area), + ( + vec![false], + Direction::Vertical, + 47, + Rect::new(3, 7, 60, 80), + ), + ( + vec![true], + Direction::Vertical, + 27, + Rect::new(63, 7, 60, 80), + ), + ]) { + assert_eq!( + (split.path.clone(), split.direction, split.pos, split.area), + (path, direction, pos, rect) + ); + } + assert!(layout.set_ratio_at(&splits[2].path, 0.5)); + let resized = layout.splits(area); + assert_eq!(resized[1].pos, splits[1].pos); + assert_eq!(resized[2].pos, 47); + } + + #[test] + fn rejected_splits_and_insertions_preserve_layout_and_focus_history() { + let (mut layout, root) = TileLayout::new(); + let second = layout.split_focused(Direction::Horizontal); + let absent = PaneId::alloc(); + let before = pane_rects(&layout); + let splits = split_snapshot(&layout); + assert!(layout + .split_pane(absent, Direction::Vertical, 0.5) + .is_none()); + for (target, moved) in [(absent, PaneId::alloc()), (root, second), (root, root)] { + assert!(!layout.insert_pane_near(target, moved, Direction::Vertical, 0.3, true)); + } + assert_eq!(pane_rects(&layout), before); + assert_eq!(split_snapshot(&layout), splits); + assert_eq!(layout.focused(), second); + assert_eq!(layout.prev_focus, Some(root)); + assert!(layout.close_focused()); + assert_eq!(layout.focused(), root); + } + + #[test] + fn splitting_a_deep_leaf_preserves_other_branches_and_focus() { + let (mut layout, root) = TileLayout::new(); + let right = layout.split_pane(root, Direction::Horizontal, 0.6).unwrap(); + let bottom_left = layout.split_pane(root, Direction::Vertical, 0.4).unwrap(); + let right_rect = pane_rect(&layout, right); + let focus = layout.focused(); + let new = layout + .split_pane(bottom_left, Direction::Horizontal, f32::NAN) + .unwrap(); + assert_eq!(layout.pane_ids(), [root, bottom_left, new, right]); + assert_eq!(pane_rect(&layout, right), right_rect); + assert_eq!(layout.focused(), focus); + assert_eq!( + split_snapshot(&layout), + [ + (Direction::Horizontal, 0.6), + (Direction::Vertical, 0.4), + (Direction::Horizontal, 0.5) + ] + ); + } + + #[test] + #[ignore = "manual BSP allocation and traversal scaling profile"] + fn bsp_layout_profile() { + use std::hint::black_box; + use std::time::{Duration, Instant}; + fn build(count: usize, balanced: bool) -> TileLayout { + let (mut layout, root) = TileLayout::new(); + let mut leaves = std::collections::VecDeque::from([root]); + for _ in 1..count { + let target = leaves.pop_front().unwrap(); + let new = layout + .split_pane(target, Direction::Horizontal, 0.5) + .unwrap(); + if balanced { + leaves.push_back(target); + } + leaves.push_back(new); + } + layout + } + fn measure(mut operation: impl FnMut()) -> f64 { + for _ in 0..32 { + operation(); + } + let mut samples = Vec::new(); + for _ in 0..7 { + let start = Instant::now(); + let mut iterations = 0; + while start.elapsed() < Duration::from_millis(20) { + operation(); + iterations += 1; + } + samples.push(start.elapsed().as_secs_f64() * 1e6 / f64::from(iterations)); + } + samples.sort_by(f64::total_cmp); + samples[3] + } + for count in [1, 15, 128, 512] { + for balanced in [true, false] { + let layout = build(count, balanced); + let splits = measure(|| { + black_box(layout.splits(Rect::new(0, 0, 120, 40))); + }); + let build = measure(|| { + black_box(build(count, balanced)); + }); + println!("bsp panes={count} balanced={balanced} splits_us={splits:.3} build_us={build:.3}"); + } + } + } + fn pane(id: u32) -> PaneId { PaneId::from_raw(id) } From 87c08c6b7a1dbd250fa3a8e9424a47b3176f99eb Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Mon, 28 Sep 2026 03:58:55 +0800 Subject: [PATCH 82/99] perf: skip frame clones for unchanged retained graphics (#4672) --- justfile | 4 ++ src/server/client_shell_graphics.rs | 5 +- src/server/headless/retained_surface.rs | 13 ++-- src/server/headless/tests/pane_graphics.rs | 78 +++++++++++++++++++--- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/justfile b/justfile index ebe6c571..4acfda02 100644 --- a/justfile +++ b/justfile @@ -95,6 +95,10 @@ bench-terminal-targets: 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 + # ~3-5 minute CPU comparison; downloads stable unless HERDR_PERF_BASELINE_BIN is set bench-release-smoke: cargo build --release --locked diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs index d0af03a9..8499b964 100644 --- a/src/server/client_shell_graphics.rs +++ b/src/server/client_shell_graphics.rs @@ -3,7 +3,7 @@ use crate::protocol::{ClientShellPopupSurface, SurfaceGraphicsScene}; pub(crate) fn collect_retained( app: &crate::app::App, - surface: &crate::protocol::PaneSurfaceFrame, + panes: &[crate::protocol::PaneSurfacePane], target: crate::ui::TabSurfaceTarget, cell_size: crate::kitty_graphics::HostCellSize, delivered: &DeliveryCache, @@ -12,8 +12,7 @@ pub(crate) fn collect_retained( let rect = |rect: crate::protocol::SurfaceRect| { ratatui::layout::Rect::new(rect.x, rect.y, rect.width, rect.height) }; - let pane_infos = surface - .panes + let pane_infos = panes .iter() .map(|pane| { let (workspace_index, id) = app.parse_pane_id(&pane.pane_id)?; diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs index bf98bc3e..2a98536f 100644 --- a/src/server/headless/retained_surface.rs +++ b/src/server/headless/retained_surface.rs @@ -445,12 +445,10 @@ impl HeadlessServer { fallback!("graphics_target"); }; let client = &self.clients[&client_id]; - let mut next_surface = surface.clone(); - crate::server::render_stream::apply_pane_surface_patch(&mut next_surface, &patch); let Some((graphics, delivery, sources)) = crate::server::client_shell_graphics::collect_retained( &self.app, - &next_surface, + &panes, target, client.cell_size, &client.shell_graphics_delivery, @@ -460,14 +458,19 @@ impl HeadlessServer { fallback!("graphics_geometry"); }; graphics_changed = graphics != surface.graphics; - next_surface.graphics = graphics; - Some((next_surface, delivery, sources)) + Some((graphics, delivery, sources)) } else { None }; if patch.rows.is_empty() && !cursor_changed && !metadata_changed && !graphics_changed { continue; } + let graphics = graphics.map(|(graphics, delivery, sources)| { + let mut next_surface = surface.clone(); + crate::server::render_stream::apply_pane_surface_patch(&mut next_surface, &patch); + next_surface.graphics = graphics; + (next_surface, delivery, sources) + }); updates.push(RetainedRecipientUpdate { client_id, patch, diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 1255f4eb..70904fd5 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -4,6 +4,62 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati receiver.recv_timeout(timeout).unwrap() } +#[tokio::test] +async fn unchanged_retained_graphics_leave_the_committed_surface_and_delivery_untouched() { + let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control( + b"populated terminal\r\n\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let client = server.clients.get_mut(&1).unwrap(); + client.mode = ClientConnectionMode::ClientShell; + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + server.render_and_stream(); + let _ = receive_render(&client_rx, Duration::from_millis(100)); + + let before = server.clients[&1] + .render_state + .last_pane_surface() + .unwrap() + .clone(); + assert_eq!(before.graphics.placements.len(), 1); + assert!(before.graphics.assets.is_empty()); + let cells = server.clients[&1] + .render_state + .last_pane_surface() + .unwrap() + .frame + .cells + .as_ptr(); + let mut hidden = crate::workspace::Workspace::test_new("hidden"); + let hidden_pane = hidden.tabs[0].root_pane; + hidden.insert_test_runtime( + hidden_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hidden output"), + ); + server.app.state.workspaces.push(hidden); + for source in [pane_id, hidden_pane] { + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([source]))); + assert!(client_rx.try_recv().is_err()); + let after = server.clients[&1].render_state.last_pane_surface().unwrap(); + assert_eq!(after, &before); + assert_eq!(after.frame.cells.as_ptr(), cells); + } + + // Skipping the no-op must not discard the upload cache for the next real update. + write_shared_test_pane(&mut server, pane_id, b"\x1b[Hchanged"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let ServerMessage::PaneSurface(updated) = + read_server_message(receive_render(&client_rx, Duration::from_millis(100))) + else { + panic!("expected retained image with changed text"); + }; + assert!(updated.graphics.assets.is_empty()); + assert_eq!(updated.graphics.placements, before.graphics.placements); + assert!(frame_text(&updated.frame).contains("changed")); +} + #[tokio::test] async fn first_kitty_image_updates_retained_surface_without_full_redraw() { let (mut server, _control_rx, client_rx, pane_id) = @@ -241,7 +297,7 @@ async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload async fn render_scale_profile_retained_graphics() { use ratatui::layout::Direction; for retained in [false, true] { - for image in ["none", "visible", "offscreen"] { + for image in ["none", "visible", "offscreen", "unchanged-image"] { for count in [1, 15] { let (mut server, _control_rx, client_rx, root) = retained_test_server_with_control(b"populated terminal\r\n"); @@ -289,12 +345,14 @@ async fn render_scale_profile_retained_graphics() { let sources = pane_ids.iter().copied().collect(); let mut samples = Vec::new(); for sample in 0..110 { - for id in &pane_ids { - write_shared_test_pane( - &mut server, - *id, - format!("\x1b[H{sample:03}").as_bytes(), - ); + if image != "unchanged-image" { + for id in &pane_ids { + write_shared_test_pane( + &mut server, + *id, + format!("\x1b[H{sample:03}").as_bytes(), + ); + } } let started = Instant::now(); if retained { @@ -314,9 +372,9 @@ async fn render_scale_profile_retained_graphics() { } samples.sort_unstable(); println!( - "retained={retained} 80x24 panes={count} image={image} median_us={} p95_us={}", - samples[50].as_micros(), - samples[94].as_micros() + "retained={retained} 80x24 panes={count} image={image} median_us={:.3} p95_us={:.3}", + samples[50].as_secs_f64() * 1e6, + samples[94].as_secs_f64() * 1e6 ); } } From c210c953e454882c5ef3cd366bcd4b53b7c64791 Mon Sep 17 00:00:00 2001 From: Eric Yue <jiacheng.yue@foxmail.com> Date: Mon, 28 Sep 2026 04:16:55 +0800 Subject: [PATCH 83/99] fix: bound external event batches in the server loop (#4671) --- justfile | 4 + src/server/headless.rs | 15 +- src/server/headless/tests/event_fairness.rs | 183 ++++++++++++++++++++ src/server/headless/tests/mod.rs | 1 + 4 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/server/headless/tests/event_fairness.rs diff --git a/justfile b/justfile index 4acfda02..af867d6f 100644 --- a/justfile +++ b/justfile @@ -99,6 +99,10 @@ bench-bsp-layout: 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/src/server/headless.rs b/src/server/headless.rs index fe8a67e8..da3baf30 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -82,6 +82,11 @@ mod render; mod retained_surface; mod surface_interest; +// Producers can refill even a bounded channel while it is being drained. +// Yield to scheduled work and rendering between batches; select! below +// immediately wakes for any messages left in either external queue. +const EXTERNAL_EVENT_DRAIN_LIMIT: usize = 64; + pub use bootstrap::run_server; use lifecycle::wait_for_live_handoff_response_write; #[cfg(unix)] @@ -1011,7 +1016,10 @@ impl HeadlessServer { /// Drains server events from the dedicated channel. fn drain_server_events(&mut self) -> bool { let mut changed = false; - while !self.should_quit.load(Ordering::Acquire) { + for _ in 0..EXTERNAL_EVENT_DRAIN_LIMIT { + if self.should_quit.load(Ordering::Acquire) { + break; + } let Ok(ev) = self.server_event_rx.try_recv() else { break; }; @@ -2788,7 +2796,10 @@ impl HeadlessServer { /// During shutdown, remaining requests get a `server_unavailable` error. fn drain_api_requests_with_shutdown_check(&mut self) -> bool { let mut changed = false; - while !self.should_quit.load(Ordering::Acquire) { + for _ in 0..EXTERNAL_EVENT_DRAIN_LIMIT { + if self.should_quit.load(Ordering::Acquire) { + break; + } let Ok(msg) = self.app.api_rx.try_recv() else { break; }; diff --git a/src/server/headless/tests/event_fairness.rs b/src/server/headless/tests/event_fairness.rs new file mode 100644 index 00000000..49e60936 --- /dev/null +++ b/src/server/headless/tests/event_fairness.rs @@ -0,0 +1,183 @@ +use super::*; + +fn queue_requests(server: &mut HeadlessServer, count: usize) -> std::sync::mpsc::Receiver<String> { + let (sender, receiver) = mpsc::unbounded_channel(); + server.app.api_rx = receiver; + let (respond_to, responses) = std::sync::mpsc::channel(); + for index in 0..count { + sender + .send(api::ApiRequestMessage { + request: api::schema::Request { + id: index.to_string(), + method: api::schema::Method::WorkspaceList(api::schema::EmptyParams::default()), + }, + respond_to: respond_to.clone(), + response_write_complete: None, + }) + .unwrap(); + } + responses +} + +#[test] +fn external_api_batches_preserve_fifo_and_leave_work_for_the_next_turn() { + let mut server = test_headless_server(); + let count = EXTERNAL_EVENT_DRAIN_LIMIT * 2 + 1; + let responses = queue_requests(&mut server, count); + let mut processed = 0; + while !server.app.api_rx.is_empty() { + server.drain_api_requests_with_shutdown_check(); + let batch: Vec<_> = responses.try_iter().collect(); + assert_eq!( + batch.len(), + EXTERNAL_EVENT_DRAIN_LIMIT.min(count - processed) + ); + for response in batch { + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["id"], processed.to_string()); + assert!(response.get("result").is_some()); + processed += 1; + } + assert_eq!(server.app.api_rx.len(), count - processed); + } + assert_eq!(processed, count); +} + +#[test] +fn external_server_events_yield_with_backlog_and_preserve_fifo() { + let mut server = test_headless_server(); + let count = EXTERNAL_EVENT_DRAIN_LIMIT * 2 + 1; + let (sender, receiver) = mpsc::channel(count); + server.server_event_rx = receiver; + server.server_event_tx = sender.clone(); + for client_id in 0..count as u64 { + sender + .try_send(ServerEvent::ClientDisconnected { client_id }) + .unwrap(); + } + server.drain_server_events(); + assert_eq!( + server.server_event_rx.len(), + count - EXTERNAL_EVENT_DRAIN_LIMIT + ); + let ServerEvent::ClientDisconnected { client_id } = server.server_event_rx.try_recv().unwrap() + else { + panic!("expected the next disconnect"); + }; + assert_eq!(client_id, EXTERNAL_EVENT_DRAIN_LIMIT as u64); + server.drain_server_events(); + assert!(server.server_event_rx.is_empty()); +} + +#[test] +fn external_api_batch_stops_immediately_when_shutdown_is_requested() { + let mut server = test_headless_server(); + let responses = queue_requests(&mut server, EXTERNAL_EVENT_DRAIN_LIMIT + 1); + server.should_quit.store(true, Ordering::Release); + assert!(!server.drain_api_requests_with_shutdown_check()); + assert_eq!(server.app.api_rx.len(), EXTERNAL_EVENT_DRAIN_LIMIT + 1); + assert_eq!(responses.try_iter().count(), 0); +} + +#[test] +fn scheduled_work_runs_between_external_api_batches() { + let mut server = test_headless_server(); + let responses = queue_requests(&mut server, EXTERNAL_EVENT_DRAIN_LIMIT + 1); + let now = Instant::now(); + server.app.config_diagnostic_deadline = Some(now); + server.app.state.config_diagnostic = Some("expired diagnostic".into()); + + server.drain_api_requests_with_shutdown_check(); + assert_eq!(responses.try_iter().count(), EXTERNAL_EVENT_DRAIN_LIMIT); + assert_eq!(server.app.api_rx.len(), 1); + assert!(server.handle_scheduled_tasks_headless(now, false)); + assert!(server.app.state.config_diagnostic.is_none()); + assert!(server.app.config_diagnostic_deadline.is_none()); + assert_eq!(server.app.api_rx.len(), 1); +} + +#[tokio::test] +async fn server_loop_drains_api_backlog_and_runs_scheduled_work() { + let mut server = test_headless_server(); + let (sender, receiver) = mpsc::unbounded_channel(); + server.app.api_rx = receiver; + let (respond_to, responses) = std::sync::mpsc::channel(); + let count = EXTERNAL_EVENT_DRAIN_LIMIT * 2 + 1; + for index in 0..=count { + // A queued stop terminates the real loop without a concurrent producer + // or a timing assertion. Earlier requests must all receive responses. + let method = if index == count { + api::schema::Method::ServerStop(api::schema::EmptyParams::default()) + } else { + api::schema::Method::WorkspaceList(api::schema::EmptyParams::default()) + }; + sender + .send(api::ApiRequestMessage { + request: api::schema::Request { + id: index.to_string(), + method, + }, + respond_to: respond_to.clone(), + response_write_complete: None, + }) + .unwrap(); + } + server.app.config_diagnostic_deadline = Some(Instant::now()); + server.app.state.config_diagnostic = Some("expired diagnostic".into()); + + tokio::time::timeout(Duration::from_secs(5), server.run()) + .await + .expect("queued API requests must wake the server loop") + .expect("server loop shuts down cleanly"); + + let responses: Vec<_> = responses.try_iter().collect(); + assert_eq!(responses.len(), count + 1); + for (index, response) in responses.iter().enumerate() { + let response: serde_json::Value = serde_json::from_str(response).unwrap(); + assert_eq!(response["id"], index.to_string()); + assert!(response.get("result").is_some()); + } + assert!(server.app.api_rx.is_empty()); + assert!(server.app.state.config_diagnostic.is_none()); + assert!(server.app.config_diagnostic_deadline.is_none()); + assert_eq!(server.app.terminal_runtimes.len(), 0); +} + +#[test] +#[ignore = "manual external API burst scheduling profile"] +fn external_api_burst_profile() { + for pane_count in [1, 15] { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("api-profile"); + for _ in 1..pane_count { + workspace.test_split(ratatui::layout::Direction::Horizontal); + } + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + for count in [64, 512, 4096] { + let mut first_samples = Vec::new(); + let mut total_samples = Vec::new(); + let mut first_count = 0; + for sample in 0..10 { + let responses = queue_requests(&mut server, count); + let start = Instant::now(); + server.drain_api_requests_with_shutdown_check(); + let first = start.elapsed(); + first_count = count - server.app.api_rx.len(); + while !server.app.api_rx.is_empty() { + server.drain_api_requests_with_shutdown_check(); + } + let total = start.elapsed(); + assert_eq!(responses.try_iter().count(), count); + if sample >= 3 { + first_samples.push(first); + total_samples.push(total); + } + } + first_samples.sort_unstable(); + total_samples.sort_unstable(); + println!("api-burst panes={pane_count} requests={count} first_count={first_count} first_us={:.3} total_us={:.3}", + first_samples[3].as_secs_f64() * 1e6, total_samples[3].as_secs_f64() * 1e6); + } + } +} diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index bbb7bfe6..2dd082d6 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1,5 +1,6 @@ use super::*; +mod event_fairness; mod native_graphics; #[path = "pane_move.rs"] mod pane_move_tests; From 2527eed26354dd18090b3de29f7fa0f7a1872540 Mon Sep 17 00:00:00 2001 From: Michael Hackner <mhackner@gmail.com> Date: Sun, 27 Sep 2026 13:31:20 -0700 Subject: [PATCH 84/99] fix: make release test robust to diff.noPrefix git config (#4663) `git apply` by default expects `-p1`, which is the default behavior of `git diff`, but this can be overridden by config. Specify it explicitly so it works regardless. --- .github/workflows/release.yml | 2 +- scripts/test_release.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64ae182b..2991afe7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -455,7 +455,7 @@ jobs: exit 0 fi # Apply only release preparation, never the hotfix implementation or newer master code. - git diff --binary "$PREVIEW_COMMIT" "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-preparation.patch" + git diff --binary --default-prefix "$PREVIEW_COMMIT" "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-preparation.patch" if [ -s "$RUNNER_TEMP/release-preparation.patch" ]; then git apply --3way --index "$RUNNER_TEMP/release-preparation.patch" fi diff --git a/scripts/test_release.py b/scripts/test_release.py index f495c89c..bf79c0e8 100644 --- a/scripts/test_release.py +++ b/scripts/test_release.py @@ -193,7 +193,10 @@ def test_hotfix_isolated_from_master_and_metadata_sync_preserves_master_code(sel release.select_hotfix("release/hotfix", "v2.0.0") candidate = self.prepare() release.validate_diff(hotfix, candidate, "1.0.1") - patch = subprocess.check_output(["git", "diff", "--binary", hotfix, candidate]) + # Reproduce a developer git config with diff prefixes disabled; the + # explicit --default-prefix must keep the patch applicable to git apply -p1. + self.git("config", "diff.noprefix", "true") + patch = subprocess.check_output(["git", "diff", "--binary", "--default-prefix", hotfix, candidate]) self.git("checkout", "-q", "master") subprocess.run(["git", "apply", "--3way", "--index"], input=patch, check=True) self.assertEqual(Path("src/feature.rs").read_text(), "b and c\n") From 0d5d6f1f317e238c8297076bc6ab5c3a0cd56283 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Sun, 27 Sep 2026 22:38:56 +0200 Subject: [PATCH 85/99] test: fix pane shell idle foreground-command race (#4691) The interactive-shell idle test sent SIGINT as soon as it observed the shell was busy. That condition can be true before the shell finishes moving the command into its own foreground process group, so the interrupt could arrive before the command reset its signal dispositions. The command then survived in a stale foreground group while the shell showed a prompt, and the pane was never observed idle again. Run cat and end it with EOF instead. cat blocks on stdin, so the busy state is stable, and EOF avoids the interrupt/job-control race. --- src/detect/mod.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 7d7759d2..146c3089 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -1962,9 +1962,15 @@ mod tests { let mut writer = pair.master.take_writer().expect("no writer"); assert!(wait_for(true, pid), "interactive shell should start idle"); - writer.write_all(b"sleep 999\n").unwrap(); + // End the foreground command with EOF rather than SIGINT. An interrupt + // sent while the shell is still moving the command into its own + // foreground process group can be delivered before the command resets + // its signal dispositions, leaving it running in a stale foreground + // group while the shell shows a prompt. `cat` blocks until stdin hits + // EOF, so it stays reliably busy until we end it. + writer.write_all(b"cat\n").unwrap(); assert!(wait_for(false, pid), "shell running a command is not idle"); - writer.write_all(&[3]).unwrap(); + writer.write_all(&[4]).unwrap(); assert!( wait_for(true, pid), "shell should be idle after the command ends" From 7f89b11a11160874ba89f3be7c64b29960bd2df3 Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Mon, 28 Sep 2026 11:58:09 +0200 Subject: [PATCH 86/99] feat: support multiple prefix keys (#4653) * feat: support multiple prefix keys keys.prefix accepts an array in addition to a single string so one config can define several prefix keys. Every configured prefix enters the same prefix mode, and the help panel lists them all. Published keybinding profiles keep the primary prefix as a scalar and carry the rest in an optional extra_prefixes field so older clients keep working with the primary prefix. * fix: reject an empty prefix list on reload An explicitly empty keys.prefix array now stays empty through config parsing so validation rejects it and a reload keeps the current keybindings instead of silently falling back to ctrl+b. --- .../src/content/docs/configuration.mdx | 9 + .../website/src/data/config-reference.json | 4 +- src/app/mod.rs | 39 ++-- src/app/state.rs | 6 +- src/client/shell/config.rs | 12 +- src/client/shell/input.rs | 10 +- src/client/shell/overlays.rs | 2 +- src/client/shell/render.rs | 2 +- src/client/shell/tests/copy.rs | 4 +- src/client/shell/tests/input.rs | 25 +++ .../shell/tests/keybindings_settings.rs | 4 +- src/config.rs | 45 +++- src/config/keybinds.rs | 209 +++++++++++++++--- src/config/model.rs | 43 +++- src/input/keybind_help.rs | 18 +- src/server/keybindings.rs | 5 +- 16 files changed, 347 insertions(+), 90 deletions(-) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 1a0ce53c..33644e1c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -142,6 +142,15 @@ navigate_pane_down = "ctrl+j" split_horizontal = "prefix+minus" ``` +`keys.prefix` also accepts an array when you need to enter prefix mode from more than one key, for example to share one config between macOS and a remote Linux machine: + +```toml +[keys] +prefix = ["ctrl+space", "ctrl+s"] +``` + +Every configured prefix enters the same prefix mode and starts every `prefix+...` action. The first entry is the primary prefix shown in the status bar; the in-app help panel at `prefix+?` lists all of them. + The default keymap is prefix-first so Herdr does not steal input from shells, editors, tmux, or terminal apps. Search `keys.` in the [Config reference](/docs/config-reference/) to see every action and default binding. The in-app help panel at `prefix+?` shows the active bindings. A binding may also be an array when one action needs multiple shortcuts: diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 299c2ced..eb7e08d4 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -477,9 +477,9 @@ "keys": [ { "key": "keys.prefix", - "type": "string", + "type": "keybinding", "default": "\"ctrl+b\"", - "description": "Prefix key to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\")." + "description": "Prefix key(s) to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\", or an array to accept several)." }, { "key": "keys.help", diff --git a/src/app/mod.rs b/src/app/mod.rs index e358a562..8fad961c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -361,7 +361,7 @@ impl App { api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, 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::<AppEvent>(APP_EVENT_CHANNEL_CAPACITY); let render_notify = Arc::new(Notify::new()); @@ -479,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, @@ -799,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), @@ -1706,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 @@ -1804,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); @@ -1959,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, @@ -2027,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 @@ -2117,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/state.rs b/src/app/state.rs index 892ec5e9..65c06a6c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -820,8 +820,7 @@ pub struct AppState { /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option<bool>, // 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 +1045,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/client/shell/config.rs b/src/client/shell/config.rs index 2d16b00f..bfa1fb2f 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -139,7 +139,7 @@ impl ClientShellConfig { .live_keybinds_with_diagnostics() .map(|(keybinds, _diagnostics)| keybinds) .unwrap_or_else(|_diagnostics| LiveKeybindConfig { - prefix: config.prefix_key(), + prefix: config.prefix_keys(), keybinds: config.keybinds(), }), local_keys: config.keys.clone(), @@ -459,7 +459,7 @@ mod tests { next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; next.ui.sidebar.agents = toml::from_str("rows = [[{ token = 'machine', rules = [{ equals = 'Local', bold = true }] }]]\nrow_gap = 2").unwrap(); - next.keys.prefix = "ctrl+a".to_owned(); + next.keys.prefix = crate::config::BindingConfig::one("ctrl+a"); let diagnostics = shell.apply_live_config(&next, &[], &[]); @@ -491,7 +491,7 @@ mod tests { assert_eq!(shell.agents, previous); assert_eq!( shell.keybinds.prefix, - (KeyCode::Char('a'), KeyModifiers::CONTROL) + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] ); } @@ -523,19 +523,19 @@ mod tests { fn live_reload_preserves_invalid_client_owned_sections() { let mut initial = Config::default(); initial.ui.sidebar_width = 29; - initial.keys.prefix = "ctrl+x".to_owned(); + initial.keys.prefix = crate::config::BindingConfig::one("ctrl+x"); let mut shell = ClientShellConfig::from_config(&initial); let mut invalid = Config::default(); invalid.ui.sidebar_width = 35; - invalid.keys.prefix = "ctrl+a".to_owned(); + invalid.keys.prefix = crate::config::BindingConfig::one("ctrl+a"); let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()]; shell.apply_live_config(&invalid, &[], &invalid_sections); assert_eq!(shell.sidebar_width, 29); assert_eq!( shell.keybinds.prefix, - (KeyCode::Char('x'), KeyModifiers::CONTROL) + vec![(KeyCode::Char('x'), KeyModifiers::CONTROL)] ); } } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 0e14e18c..413e29f5 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -569,7 +569,7 @@ impl ClientShellState { self.record_binding(binding, outcome); return None; } - if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + if self.config.keybinds.matches_prefix(key) { self.mode = ClientShellMode::Prefix; outcome.repaint = true; return None; @@ -584,7 +584,7 @@ impl ClientShellState { } else { ClientShellMode::Terminal }; - if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + if self.config.keybinds.matches_prefix(key) { self.mode = return_mode; outcome.repaint = true; return self.focused_pane_id().map(ClientInputTarget::Pane); @@ -619,7 +619,7 @@ impl ClientShellState { .copy_mode .as_ref() .is_none_or(|copy_mode| copy_mode.search_prompt.is_none()) - && crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) + && self.config.keybinds.matches_prefix(key) { self.mode = ClientShellMode::Prefix; outcome.repaint = true; @@ -649,9 +649,7 @@ impl ClientShellState { use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; self.pending_workspace_highlight = None; - if key.code == KeyCode::Esc - || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) - { + if key.code == KeyCode::Esc || self.config.keybinds.matches_prefix(key) { self.mode = self.copy_or_terminal_mode(); self.navigate_workspace_id = None; outcome.repaint = true; diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index c8eee5f4..7116713b 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -1044,7 +1044,7 @@ fn help_lines( use ratatui::text::{Line, Span}; let groups = crate::input::filter_keybind_help_groups( - crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix), + crate::input::keybind_help_groups(&keybinds.keybinds, &keybinds.prefix), query, ); let key_width = groups diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 82cb2ac4..248216c1 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -68,7 +68,7 @@ pub(super) fn render_mode_bar( palette.accent }) .add_modifier(Modifier::BOLD); - let prefix = crate::config::format_key_combo(keybinds.prefix); + let prefix = keybinds.primary_prefix_label(); let prefix_rhs = |bindings: &crate::config::ActionKeybinds| { bindings .prefix_rhs_label() diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index c0d528e1..317950cf 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1856,7 +1856,7 @@ fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { .as_ref() .is_some_and(|copy_mode| copy_mode.selection.is_some())); - let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix[0]; state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( prefix_key, prefix_modifiers, @@ -2038,7 +2038,7 @@ fn queued_copy_keys_preserve_prefix_order() { let origin = state.copy_mode.as_ref().expect("copy mode").cursor; let motion = state.handle_input_bytes(b"w"); state.handle_input_bytes(b"l"); - let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix[0]; state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( prefix_key, prefix_modifiers, diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index b54548f2..145c7ae6 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -650,3 +650,28 @@ fn styled_client_composition_preserves_pane_hyperlinks() { let link = frame.cells[index].hyperlink.expect("linked cell") as usize; assert_eq!(frame.hyperlinks[link], "https://example.test"); } + +#[test] +fn every_configured_prefix_enters_prefix_mode() { + let mut config = Config::default(); + config.keys.prefix = + crate::config::BindingConfig::Many(vec!["ctrl+space".to_owned(), "ctrl+s".to_owned()]); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + + for combo in [ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ] { + let _ = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + combo.0, combo.1, + ))]); + assert_eq!(state.mode, ClientShellMode::Prefix); + + let _ = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + } +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index e1b43a01..dd60fe11 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -282,7 +282,7 @@ command = "local-only" .unwrap(); let remote_local = ClientShellConfig::from_config(&local) .with_keybinding_source(ClientShellKeybindingSource::RemoteLocal); - assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a')); + assert_eq!(remote_local.keybinds.prefix[0].0, KeyCode::Char('a')); assert!(remote_local.keybinds.keybinds.custom_commands.is_empty()); assert_eq!( remote_local.keybinds.keybinds.new_tab.label().as_deref(), @@ -377,7 +377,7 @@ new_tab = "prefix+n" }); state.set_snapshot(Box::new(projection)); - assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x')); + assert_eq!(state.config.keybinds.prefix[0].0, KeyCode::Char('x')); assert_eq!( state.config.keybinds.keybinds.new_tab.label().as_deref(), Some("prefix+n") diff --git a/src/config.rs b/src/config.rs index 60a22a1e..8550097d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,9 +17,9 @@ pub use self::{ upsert_section_value, }, keybinds::{ - format_key_combo, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, + format_prefix_combos, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, BindingConfig, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, - IndexedKeybind, Keybinds, LiveKeybindConfig, + IndexedKeybind, KeyCombo, Keybinds, LiveKeybindConfig, }, model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, @@ -102,7 +102,7 @@ impl Config { .unwrap_or(true) } - pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) { + pub fn prefix_keys(&self) -> Vec<(KeyCode, KeyModifiers)> { self.validated_keybinds().1 } @@ -182,7 +182,7 @@ impl Config { } let mut keys = self.keys.local_profile(&self.keybinds()); - keys.set_prefix(format_key_combo(self.prefix_key())); + keys.set_prefixes(&self.prefix_keys()); toml::to_string_pretty(&KeysProfile { keys }) } } @@ -239,7 +239,42 @@ prefix = "ctrl+" let keybinds = keybindings_from_profile_toml(&profile).unwrap(); assert!(profile.contains("prefix = \"ctrl+b\"")); - assert_eq!(keybinds.prefix, config.prefix_key()); + assert_eq!(keybinds.prefix, config.prefix_keys()); + } + + #[test] + fn local_keybindings_profile_publishes_additional_prefixes_for_old_clients() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +"#, + ) + .unwrap(); + + let profile = config.local_keybindings_profile_toml().unwrap(); + + // Generation-1 clients parse `prefix` as a single string; the extra + // prefixes ride in the optional `extra_prefixes` field they ignore. + assert!(profile.contains("prefix = \"ctrl+space\"")); + assert!(!profile.contains("prefix = [")); + assert!(profile.contains("extra_prefixes = [\"ctrl+s\"]")); + + let keybinds = keybindings_from_profile_toml(&profile).unwrap(); + assert_eq!(keybinds.prefix, config.prefix_keys()); + } + + #[test] + fn local_keybindings_profile_omits_extra_prefixes_for_a_single_prefix() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+b" +"#, + ) + .unwrap(); + let profile = config.local_keybindings_profile_toml().unwrap(); + assert!(!profile.contains("extra_prefixes")); } #[test] diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 5f4b46de..903f85ca 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -10,12 +10,38 @@ use crate::popup_size::PopupSize; pub type KeyCombo = (KeyCode, KeyModifiers); +/// Built-in prefix used when `keys.prefix` is unset or invalid. +pub(crate) const DEFAULT_PREFIX: KeyCombo = (KeyCode::Char('b'), KeyModifiers::CONTROL); + #[derive(Debug, Clone)] pub struct LiveKeybindConfig { - pub prefix: KeyCombo, + /// Every configured prefix key. The first entry is the primary prefix used + /// for compact display; all entries enter prefix mode. + pub prefix: Vec<KeyCombo>, pub keybinds: Keybinds, } +impl LiveKeybindConfig { + /// Whether `key` is one of the configured prefix keys. + pub fn matches_prefix(&self, key: &TerminalKey) -> bool { + self.prefix + .iter() + .any(|combo| terminal_key_matches_combo(key, *combo)) + } + + /// Primary prefix combo, used for the compact status bar. + pub fn primary_prefix(&self) -> Option<KeyCombo> { + self.prefix.first().copied() + } + + /// Primary prefix rendered for the compact status bar. + pub fn primary_prefix_label(&self) -> String { + self.primary_prefix() + .map(format_key_combo) + .unwrap_or_else(|| format_key_combo(DEFAULT_PREFIX)) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub enum BindingConfig { @@ -45,6 +71,13 @@ impl BindingConfig { } } + pub(crate) fn into_values(self) -> Vec<String> { + match self { + Self::One(value) => vec![value], + Self::Many(values) => values, + } + } + pub(crate) fn has_values(&self) -> bool { self.values().iter().any(|value| !value.trim().is_empty()) } @@ -397,16 +430,19 @@ struct RegisteredBinding { } struct BindingRegistry { - prefix_combo: KeyCombo, + prefix_combos: Vec<KeyCombo>, prefix_source: BindingSource, direct: std::collections::HashMap<KeyCombo, RegisteredBinding>, prefix: std::collections::HashMap<KeyCombo, RegisteredBinding>, } impl BindingRegistry { - fn new(prefix_combo: KeyCombo, prefix_source: BindingSource) -> Self { + fn new(prefix_combos: Vec<KeyCombo>, prefix_source: BindingSource) -> Self { + let mut normalized: Vec<KeyCombo> = + prefix_combos.into_iter().map(normalize_key_combo).collect(); + normalized.dedup(); Self { - prefix_combo: normalize_key_combo(prefix_combo), + prefix_combos: normalized, prefix_source, direct: std::collections::HashMap::new(), prefix: std::collections::HashMap::new(), @@ -422,8 +458,18 @@ impl BindingRegistry { }); } + /// Reserve every configured prefix key as a direct/modifier key so no + /// action can hijack it before prefix mode starts. + fn reserve_prefix_keys(&mut self, field: &str, source: BindingSource) { + let combos = self.prefix_combos.clone(); + for combo in combos { + self.reserve_direct(combo, field, source); + } + } + fn prefix_rhs_is_reserved(&self, combo: KeyCombo) -> bool { - normalize_key_combo(combo) == self.prefix_combo + let combo = normalize_key_combo(combo); + self.prefix_combos.contains(&combo) } fn conflict(&self, binding: &ResolvedBinding) -> Option<&RegisteredBinding> { @@ -450,27 +496,28 @@ impl BindingRegistry { } impl Config { - pub(super) fn validated_keybinds(&self) -> (Option<String>, KeyCombo, Vec<String>, Keybinds) { - let mut diagnostics = Vec::new(); - let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( - &self.keys.prefix, - "keys.prefix", - (KeyCode::Char('b'), KeyModifiers::CONTROL), - ); + pub(super) fn validated_keybinds( + &self, + ) -> (Option<String>, Vec<KeyCombo>, Vec<String>, Keybinds) { + let (prefix_keys, prefix_diag, mut diagnostics) = parse_prefix_keys(&self.keys.prefix); if let Some(diag) = &prefix_diag { warn!(message = %diag, "config diagnostic"); } + for diag in &diagnostics { + warn!(message = %diag, "config diagnostic"); + } let prefix_source = if self.keys.key_field_is_user_configured("prefix") { BindingSource::User } else { BindingSource::Default }; - let mut registry = BindingRegistry::new(prefix, prefix_source); - registry.reserve_direct(prefix, "keys.prefix", prefix_source); - let mut navigate_registry = BindingRegistry::new(prefix, prefix_source); - navigate_registry.reserve_direct(prefix, "keys.prefix", prefix_source); + let mut registry = BindingRegistry::new(prefix_keys.clone(), prefix_source); + registry.reserve_prefix_keys("keys.prefix", prefix_source); + let mut navigate_registry = BindingRegistry::new(prefix_keys.clone(), prefix_source); + navigate_registry.reserve_prefix_keys("keys.prefix", prefix_source); reserve_navigate_runtime_keys(&mut navigate_registry); + let prefix = prefix_keys; macro_rules! empty_action { () => { @@ -1306,19 +1353,45 @@ fn single_key_char(s: &str) -> Option<char> { } } -fn parse_key_combo_with_diagnostic( - s: &str, - field: &str, - fallback: KeyCombo, -) -> (KeyCombo, Option<String>) { - match parse_key_combo(s) { - Some(binding) => (binding, None), - None => { - let diag = format!("invalid keybinding: {field} = {s:?}; using fallback"); - warn!(message = %diag, "config diagnostic"); - (fallback, Some(diag)) +/// Parse the configured prefix keys. Returns the effective list, an optional +/// rejection diagnostic (used when nothing valid remains, so the caller keeps +/// the previous keybinds), and per-entry diagnostics. +fn parse_prefix_keys(config: &BindingConfig) -> (Vec<KeyCombo>, Option<String>, Vec<String>) { + let mut combos: Vec<KeyCombo> = Vec::new(); + let mut diagnostics = Vec::new(); + + for raw in config.values() { + let raw = raw.trim(); + match parse_key_combo(raw) { + Some(combo) if !combos.contains(&combo) => combos.push(combo), + Some(_) => {} + None => diagnostics.push(format!( + "invalid keybinding: keys.prefix = {raw:?}; ignoring prefix" + )), } } + + if combos.is_empty() { + let reject = Some(format!( + "invalid keybinding: keys.prefix; using fallback {}", + format_key_combo(DEFAULT_PREFIX) + )); + return (vec![DEFAULT_PREFIX], reject, diagnostics); + } + + (combos, None, diagnostics) +} + +/// Render all configured prefix keys for the help panel. +pub fn format_prefix_combos(prefixes: &[KeyCombo]) -> String { + if prefixes.is_empty() { + return format_key_combo(DEFAULT_PREFIX); + } + prefixes + .iter() + .map(|combo| format_key_combo(*combo)) + .collect::<Vec<_>>() + .join(" / ") } pub fn normalize_key_combo((mut code, mut modifiers): KeyCombo) -> KeyCombo { @@ -1532,8 +1605,8 @@ prefix = "ö" ) .unwrap(); assert_eq!( - config.prefix_key(), - (KeyCode::Char('ö'), KeyModifiers::empty()) + config.prefix_keys(), + vec![(KeyCode::Char('ö'), KeyModifiers::empty())] ); assert!(config.collect_diagnostics().is_empty()); } @@ -1821,6 +1894,84 @@ help = "prefix+ctrl+b" assert!(!config.keybinds().help.bindings.is_empty()); } + #[test] + fn multiple_prefix_keys_parse_reserve_and_share_prefix_bindings() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +help = "prefix+?" +"#, + ) + .unwrap(); + assert_eq!( + config.prefix_keys(), + vec![ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ] + ); + assert!(config.collect_diagnostics().is_empty()); + + // A prefix action works regardless of which prefix key started prefix mode. + let help = &config.keybinds().help; + assert!( + help.matches_prefix_key(&TerminalKey::new(KeyCode::Char('?'), KeyModifiers::empty())) + ); + + // Every prefix key is reserved as a prefix-mode right-hand side. + let reserved: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +help = "prefix+ctrl+s" +"#, + ) + .unwrap(); + assert!(reserved.keybinds().help.bindings.is_empty()); + assert!(reserved + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("reserved keybinding"))); + } + + #[test] + fn invalid_prefix_entries_are_dropped_while_valid_ones_survive() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+a", "wat"] +"#, + ) + .unwrap(); + assert_eq!( + config.prefix_keys(), + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); + assert!(config + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("keys.prefix") && diag.contains("ignoring prefix"))); + } + + #[test] + fn empty_prefix_list_is_rejected_and_keeps_the_fallback() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = [] +"#, + ) + .unwrap(); + assert_eq!(config.prefix_keys(), vec![DEFAULT_PREFIX]); + assert!(config + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("keys.prefix"))); + // A reload treats the empty list as invalid and keeps the current keybinds. + assert!(config.live_keybinds_with_diagnostics().is_err()); + } + #[test] fn navigate_bindings_allow_plain_keys_and_reject_local_conflicts() { let config: Config = toml::from_str( diff --git a/src/config/model.rs b/src/config/model.rs index ce59eb2e..0e2e57ce 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -334,8 +334,9 @@ pub struct LoadedConfig { #[derive(Debug, Clone, Serialize)] pub struct KeysConfig { - /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc"). - pub prefix: String, + /// Prefix key(s) to enter prefix mode (e.g. "ctrl+b", "f12", "esc", or an + /// array to accept several). + pub prefix: BindingConfig, /// Open keybinding help. Default: "prefix+?" pub help: BindingConfig, /// Open settings. Default: "prefix+s" @@ -467,7 +468,12 @@ pub struct KeysConfig { #[serde(default)] pub(crate) struct KeysConfigOverlay { #[serde(skip_serializing_if = "Option::is_none")] - prefix: Option<String>, + prefix: Option<BindingConfig>, + /// Additional prefix keys published for cross-version compatibility. + /// Older clients parse `prefix` as a single string and ignore this field; + /// new clients merge it into the effective prefix list. + #[serde(skip_serializing_if = "Option::is_none")] + extra_prefixes: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] help: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] @@ -592,8 +598,13 @@ pub(crate) struct KeysConfigOverlay { } impl KeysConfigOverlay { - pub(crate) fn set_prefix(&mut self, prefix: String) { - self.prefix = Some(prefix); + pub(crate) fn set_prefixes(&mut self, prefixes: &[super::keybinds::KeyCombo]) { + let mut labels = prefixes + .iter() + .map(|combo| super::keybinds::format_key_combo(*combo)); + self.prefix = Some(BindingConfig::One(labels.next().unwrap_or_default())); + let extra: Vec<String> = labels.collect(); + self.extra_prefixes = (!extra.is_empty()).then_some(BindingConfig::Many(extra)); } } @@ -605,6 +616,25 @@ impl<'de> Deserialize<'de> for KeysConfig { let input = KeysConfigOverlay::deserialize(deserializer)?; let mut keys = KeysConfig::default(); + let prefix_was_supplied = input.prefix.is_some() || input.extra_prefixes.is_some(); + let mut prefix_values = Vec::new(); + if let Some(prefix) = input.prefix { + prefix_values.extend(prefix.into_values()); + } + if let Some(extra) = input.extra_prefixes { + prefix_values.extend(extra.into_values()); + } + if prefix_was_supplied { + // An explicitly empty list stays empty so prefix validation rejects + // it and a reload keeps the current keybindings. + keys.prefix = match prefix_values.len() { + 0 => BindingConfig::Many(Vec::new()), + 1 => BindingConfig::One(prefix_values.remove(0)), + _ => BindingConfig::Many(prefix_values), + }; + keys.user_fields.insert("prefix"); + } + macro_rules! apply_field { ($field:ident) => { if let Some(value) = input.$field { @@ -614,7 +644,6 @@ impl<'de> Deserialize<'de> for KeysConfig { }; } - apply_field!(prefix); apply_field!(help); apply_field!(settings); apply_field!(new_workspace); @@ -1088,7 +1117,7 @@ pub struct ExperimentalConfig { impl Default for KeysConfig { fn default() -> Self { Self { - prefix: "ctrl+b".into(), + prefix: BindingConfig::one("ctrl+b"), help: BindingConfig::one("prefix+?"), settings: BindingConfig::one("prefix+s"), new_workspace: BindingConfig::one("prefix+shift+n"), diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs index 2f89b861..cb1fdb99 100644 --- a/src/input/keybind_help.rs +++ b/src/input/keybind_help.rs @@ -63,13 +63,13 @@ fn indexed_range_prefix(bindings: &[IndexedKeybind]) -> Option<&str> { pub(crate) fn keybind_help_groups( keybinds: &Keybinds, - prefix: (crossterm::event::KeyCode, crossterm::event::KeyModifiers), + prefixes: &[crate::config::KeyCombo], ) -> Vec<KeybindHelpGroup> { let mut groups = vec![ ( "global", vec![ - entry(crate::config::format_key_combo(prefix), "prefix mode"), + entry(crate::config::format_prefix_combos(prefixes), "prefix mode"), entry(binding_label(&keybinds.help), "keybinds"), entry(binding_label(&keybinds.settings), "settings"), entry(binding_label(&keybinds.detach), "detach"), @@ -267,4 +267,18 @@ mod tests { assert_eq!(filtered[0].1[0].1, "close pane"); assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); } + + #[test] + fn help_lists_every_configured_prefix() { + let groups = keybind_help_groups( + &Keybinds::default(), + &[ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ], + ); + let global = &groups[0].1; + assert_eq!(global[0].0, "ctrl+space / ctrl+s"); + assert_eq!(global[0].1, "prefix mode"); + } } diff --git a/src/server/keybindings.rs b/src/server/keybindings.rs index 262463ff..08046ba4 100644 --- a/src/server/keybindings.rs +++ b/src/server/keybindings.rs @@ -2,7 +2,7 @@ use crate::app; pub(crate) fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { crate::config::LiveKeybindConfig { - prefix: (app.state.prefix_code, app.state.prefix_mods), + prefix: app.state.prefix_keys.clone(), keybinds: app.state.keybinds.clone(), } } @@ -11,7 +11,6 @@ pub(crate) fn apply_keybindings( app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig, ) { - app.state.prefix_code = keybindings.prefix.0; - app.state.prefix_mods = keybindings.prefix.1; + app.state.prefix_keys = keybindings.prefix.clone(); app.state.keybinds = keybindings.keybinds.clone(); } From 8c8cb49c237eec2f82192dd831ec7308905f2c0a Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Mon, 28 Sep 2026 13:16:10 +0200 Subject: [PATCH 87/99] fix(windows): read agent command lines without PROCESS_VM_READ (#4708) * fix(windows): read agent command lines without PROCESS_VM_READ Windows process command lines were read by opening each process with PROCESS_VM_READ and walking its PEB. Hardened runtimes (Electron/Node) and security products deny that access, so those processes resolved to a bare `node.exe`/`bun.exe` name with no argv, argv0, or cmdline. A runtime-wrapped agent launched from such a process was then indistinguishable from an unrelated runtime process, and the pane stayed on the shell with `agent_status: unknown`. Prefer `NtQueryInformationProcess` with `ProcessCommandLineInformation` (Windows 8.1+), which needs only `PROCESS_QUERY_LIMITED_INFORMATION`, and keep the existing PEB read as a fallback for processes that do not expose a stored command line. The fallback still requires `PROCESS_VM_READ`, so this only widens coverage. refs #4579 * fix(windows): handle growing command lines before failure test The retry added for a command line that grows between the size probe and the read was unreachable. NTSTATUS is signed, so STATUS_BUFFER_OVERFLOW, STATUS_BUFFER_TOO_SMALL, and STATUS_INFO_LENGTH_MISMATCH are all negative and were treated as failures by the earlier `status < 0` check. Check the growth statuses first and retry once with the reported size. Also read the returned UNICODE_STRING header unaligned, since a Vec<u8> buffer only guarantees byte alignment. * test(windows): keep command-line marker test process alive Passing the marker as a second argument to `ping` let `ping` exit as soon as it saw one target, so the command-line query could race the process exit. Move the marker into a `rem` after `&` so it stays in cmd.exe's own command line and `ping -n 11` keeps the process alive for the read. --- src/detect/mod.rs | 3 + src/platform/windows.rs | 153 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 146c3089..37d5481a 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -1497,6 +1497,9 @@ mod tests { #[test] fn identify_agent_in_job_detects_node_wrapped_pi_bundled_cli() { + // Hardened runtimes deny `PROCESS_VM_READ`, so the command line can come + // from a LimitedInformation query with the launcher path intact. The + // detection path must not depend on how that command line was obtained. let job = crate::platform::ForegroundJob { process_group_id: 123, processes: vec![foreground_process( diff --git a/src/platform/windows.rs b/src/platform/windows.rs index d96570a6..292be4a4 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -315,11 +315,13 @@ pub(crate) fn set_default_plugin_pane_pwd( } use windows_sys::{ + Wdk::System::Threading::ProcessCommandLineInformation, Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, Win32::{ Foundation::{ CloseHandle, GlobalFree, LocalFree, FILETIME, HANDLE, HWND, INVALID_HANDLE_VALUE, - MAX_PATH, NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, + MAX_PATH, NTSTATUS, STATUS_BUFFER_OVERFLOW, STATUS_BUFFER_TOO_SMALL, + STATUS_INFO_LENGTH_MISMATCH, STATUS_SUCCESS, UNICODE_STRING, }, Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}, Security::SECURITY_ATTRIBUTES, @@ -1829,6 +1831,19 @@ impl ProcessSnapshotCache { } fn read_process_command(pid: u32, name: &str) -> WindowsProcessCommand { + // Prefer the command-line information class: it needs only + // `PROCESS_QUERY_LIMITED_INFORMATION`, while the PEB path below also needs + // `PROCESS_VM_READ`, which hardened runtimes (Electron/Node) and security + // products deny. Without a command line an agent launched through a runtime + // is indistinguishable from a bare `node.exe`/`bun.exe` process, so the + // pane would never register as an agent. + if let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) { + if let Some(cmdline) = read_process_command_line(process.0) { + let creation_time = process_creation_time(process.0); + return WindowsProcessCommand::from_cmdline(name, creation_time, Some(cmdline)); + } + } + let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ) else { @@ -2140,6 +2155,89 @@ fn environment_variable_from_utf16(environment: &[u16], name: &str) -> Option<St None } +/// Read a process command line with only `PROCESS_QUERY_LIMITED_INFORMATION`. +/// +/// `ProcessCommandLineInformation` has been available since Windows 8.1. +/// Prefer it over walking the target PEB, which additionally requires +/// `PROCESS_VM_READ` access that hardened runtimes and security products deny. +/// +/// Returns `None` for a process without a stored command line; the caller then +/// tries the PEB path before giving up. +fn read_process_command_line(process: HANDLE) -> Option<String> { + let mut required = 0_u32; + // SAFETY: a null buffer with length 0 only asks for the required size, and + // `required` is a valid out-pointer for the duration of the call. + let status = unsafe { + NtQueryInformationProcess( + process, + ProcessCommandLineInformation, + null_mut(), + 0, + &mut required, + ) + }; + // A process with no command line is already handled as a miss below. + if status != STATUS_BUFFER_TOO_SMALL + && status != STATUS_INFO_LENGTH_MISMATCH + && status != STATUS_BUFFER_OVERFLOW + { + return None; + } + + // `required` is already at least a UNICODE_STRING sized buffer. + let mut buffer = vec![0_u8; required as usize]; + for _ in 0..2 { + // SAFETY: `buffer` is `required` bytes and both pointers are valid for + // the call; the kernel writes the length back into `required`. + let status = unsafe { + NtQueryInformationProcess( + process, + ProcessCommandLineInformation, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + }; + // These three statuses all mean the command line grew between the probe + // and the read. Some data was written; retry once with the larger buffer + // the call just reported. They are negative as `NTSTATUS`, so they must + // be checked before the failure test below. + let grew = status == STATUS_BUFFER_OVERFLOW + || status == STATUS_BUFFER_TOO_SMALL + || status == STATUS_INFO_LENGTH_MISMATCH; + if grew { + buffer = vec![0_u8; required as usize]; + continue; + } + if status < 0 { + return None; + } + break; + } + + // SAFETY: on success the kernel wrote a UNICODE_STRING followed by its + // UTF-16 contents into `buffer`. A `Vec<u8>` only guarantees byte + // alignment, so read the header unaligned. + let unicode = unsafe { buffer.as_ptr().cast::<UNICODE_STRING>().read_unaligned() }; + let length = usize::from(unicode.Length); + // A short command line leaves `Length` inside the header itself; guard + // against reading a malformed header as string data. + if length == 0 || !length.is_multiple_of(2) { + return None; + } + let string_offset = size_of::<UNICODE_STRING>(); + if string_offset + length > buffer.len() { + return None; + } + let units = buffer[string_offset..string_offset + length] + .chunks_exact(2) + .map(|unit| u16::from_ne_bytes([unit[0], unit[1]])) + .collect::<Vec<_>>(); + String::from_utf16(&units) + .ok() + .filter(|command_line| !command_line.is_empty()) +} + fn read_process_parameters(process: HANDLE) -> Option<RtlUserProcessParameters> { let mut basic_info = MaybeUninit::<PROCESS_BASIC_INFORMATION>::uninit(); let status = unsafe { @@ -3612,6 +3710,59 @@ mod tests { assert_eq!(observed.as_deref(), Some("pane-test")); } + #[test] + fn windows_process_command_line_reads_live_process_with_limited_access() { + // The point of the fix: the command line must be readable from a handle + // that does not request `PROCESS_VM_READ`. Verification against a + // process that actually denies that access needs a hardened host, which + // this suite cannot provide. + let handle = super::ProcessHandle::open( + std::process::id(), + super::PROCESS_QUERY_LIMITED_INFORMATION, + ) + .expect("open self with limited access"); + + let command_line = + super::read_process_command_line(handle.0).expect("command line must be readable"); + assert!( + !command_line.is_empty(), + "command line for the test process must not be empty" + ); + } + + #[test] + fn windows_process_command_line_reads_spawned_process_marker() { + let shell = + std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()); + // `rem` keeps the marker inside cmd.exe's own command line without + // becoming a target for `ping`, so the process stays alive for the read. + let mut child = Command::new(shell) + .args([ + "/D", + "/Q", + "/C", + "ping -n 11 127.0.0.1 > NUL & rem unique-cmdline-marker", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cmd"); + + let command_line = + super::ProcessHandle::open(child.id(), super::PROCESS_QUERY_LIMITED_INFORMATION) + .and_then(|process| super::read_process_command_line(process.0)); + + let _ = child.kill(); + let _ = child.wait(); + + let command_line = command_line.expect("command line must be readable"); + assert!( + command_line.contains("unique-cmdline-marker"), + "unexpected command line: {command_line}" + ); + } + #[test] fn windows_process_tree_selects_direct_agent_descendant() { let entries = vec![ From 80c0c07250d22f69d3fa05cb1700302d66180eb8 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Mon, 28 Sep 2026 16:19:42 +0300 Subject: [PATCH 88/99] perf: send scrolling pane output as row shifts and skip blank cells (#4711) * perf: send scrolling pane output as row shifts and skip blank cells * fix: keep expanded scroll patches disjoint for the fast presentation path --- crates/ghostty-vt/src/lib.rs | 11 + src/client/handshake.rs | 1 + src/client/mod.rs | 22 +- src/pane/terminal.rs | 15 +- src/pane/terminal/migration_tests.rs | 7 + src/protocol/endpoint.rs | 8 + src/protocol/mod.rs | 1 + src/protocol/surface_delta.rs | 8 +- src/protocol/surface_reuse.rs | 43 +- src/protocol/surface_scroll.rs | 720 ++++++++++++++++++ src/server/client_transport.rs | 7 + src/server/headless.rs | 4 + src/server/headless/tests/mod.rs | 12 + src/server/headless/tests/surface_delta.rs | 6 +- src/server/headless/tests/surface_interest.rs | 5 + src/server/headless/tests/surface_scroll.rs | 89 +++ src/server/render_scale_benchmark.rs | 39 +- src/server/render_stream.rs | 37 +- 18 files changed, 1001 insertions(+), 34 deletions(-) create mode 100644 src/protocol/surface_scroll.rs create mode 100644 src/server/headless/tests/surface_scroll.rs diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index 056ff142..7ee1e751 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -3381,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<bool, Error> { + // 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<ffi::GhosttyCell, Error> { let mut raw = ffi::GhosttyCell::default(); unsafe { diff --git a/src/client/handshake.rs b/src/client/handshake.rs index f25cc1b0..2520286c 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -200,6 +200,7 @@ pub(super) fn do_handshake( surface_active, surface_reuse: true, surface_delta: true, + surface_scroll: true, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], diff --git a/src/client/mod.rs b/src/client/mod.rs index 2cec6d2d..b78517ac 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -145,6 +145,16 @@ use crate::protocol::{self, ClientMessage, ServerMessage, MAX_GRAPHICS_FRAME_SIZ use crate::protocol::{AttachScrollDirection, AttachScrollSource, NotifyKind}; use crate::server::socket_paths::client_socket_path; +/// The decoder for whichever optional surface encodings this connection negotiated. +fn negotiated_surface_decoder( + negotiation: &endpoint::EndpointNegotiation, +) -> Option<protocol::surface_reuse::Decoder> { + let reuse = negotiation.supports_capability(protocol::surface_reuse::CAPABILITY); + let delta = negotiation.supports_capability(protocol::surface_delta::CAPABILITY); + let scroll = negotiation.supports_capability(protocol::surface_scroll::CAPABILITY); + (reuse || delta || scroll).then(|| protocol::surface_reuse::Decoder::new(delta, scroll)) +} + fn run_client_with_mode( attach_request: Option<(String, bool)>, attach_escape: Option<AttachEscapeState>, @@ -588,10 +598,7 @@ async fn run_client_loop( handshake.endpoint_methods.unwrap_or_default(), handshake.endpoint_capabilities.unwrap_or_default(), ); - let surface_reuse = negotiation.supports_capability(protocol::surface_reuse::CAPABILITY); - let surface_delta = negotiation.supports_capability(protocol::surface_delta::CAPABILITY); - let surface_decoder = (surface_reuse || surface_delta) - .then(|| protocol::surface_reuse::Decoder::new(surface_delta)); + let surface_decoder = negotiated_surface_decoder(&negotiation); let transport = start_endpoint_transport( stream, (), @@ -1285,10 +1292,7 @@ async fn run_client_loop( ) { continue; } - let surface_reuse = - negotiation.supports_capability(protocol::surface_reuse::CAPABILITY); - let surface_delta = - negotiation.supports_capability(protocol::surface_delta::CAPABILITY); + let surface_decoder = negotiated_surface_decoder(&negotiation); let agent_view_projection_supported = negotiation.supports_capability( crate::protocol::endpoint::AGENT_VIEW_PROJECTION_CAPABILITY, ); @@ -1313,8 +1317,6 @@ async fn run_client_loop( if let Some(frame) = frame { state.present_frame(frame); } - let surface_decoder = (surface_reuse || surface_delta) - .then(|| protocol::surface_reuse::Decoder::new(surface_delta)); let reader_tx = event_tx.clone(); std::thread::spawn(move || { server_reader_thread( diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 8ac4bd1c..24379d22 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2677,6 +2677,7 @@ fn ghostty_collect_dirty_patch( let mut grapheme_bytes = Vec::new(); let mut symbol_scratch = String::new(); let mut patch_rows = Vec::new(); + let blank = blank_cell_data(default_fg, default_bg); while let Some(y) = rows.next_dirty() { if y >= area_height { break; @@ -2692,6 +2693,15 @@ fn ghostty_collect_dirty_patch( let mut patch_cells = Vec::with_capacity(usize::from(area_width)); let mut x = 0u16; while x < area_width && cells.next() { + match cells.is_default_blank() { + Ok(true) => { + patch_cells.push(blank.clone()); + x += 1; + continue; + } + Ok(false) => {} + Err(_) => fallback!("raw_cell_error"), + } let Ok(basic) = cells.basic_data() else { fallback!("basic_data_error"); }; @@ -2720,10 +2730,7 @@ fn ghostty_collect_dirty_patch( patch_cells.push(cell_data_from_style(symbol, style)); x += 1; } - while x < area_width { - patch_cells.push(blank_cell_data(default_fg, default_bg)); - x += 1; - } + patch_cells.resize(usize::from(area_width), blank.clone()); patch_rows.push((y, patch_cells)); } diff --git a/src/pane/terminal/migration_tests.rs b/src/pane/terminal/migration_tests.rs index 09e5df01..aaf91f27 100644 --- a/src/pane/terminal/migration_tests.rs +++ b/src/pane/terminal/migration_tests.rs @@ -311,6 +311,13 @@ fn incremental_rows_reconstruct_full_render() { b"\x1b[3;4H", b"\x1b[?1049hALT", b"\x1b[?1049l", + // Blank cells must match the full renderer under every color source. + b"\x1b[3;1H\x1b[44m\x1b[2K\x1b[0m", + b"\x1b[4;2H\x1b[7m \x1b[0m \x1b[8m \x1b[0m", + b"\x1b[?5h", + b"\x1b[?5l", + b"\x1b[5;1H\x1b]133;A\x07$ \x1b]133;B\x07 \x1b]133;C\x07", + b"\x1b[5;1H\r\n\r\nscrolled\r\n", ] { incremental.write(bytes); full.write(bytes); diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index 2704bf65..d1e577c4 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -61,6 +61,9 @@ pub struct EndpointClientHello { /// Accept the optional surface-delta encoding on this connection. #[serde(default)] pub surface_delta: bool, + /// Accept the optional scroll-aware patch encoding on this connection. + #[serde(default)] + pub surface_scroll: bool, #[serde(default)] pub snapshot_codecs: Vec<String>, #[serde(default)] @@ -165,6 +168,7 @@ impl EndpointServerWelcome { capabilities: vec![ super::surface_reuse::CAPABILITY.into(), super::surface_delta::CAPABILITY.into(), + super::surface_scroll::CAPABILITY.into(), SURFACE_INTEREST_CAPABILITY.into(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), HEALTH_CHECK_CAPABILITY.into(), @@ -210,6 +214,7 @@ mod tests { surface_active: true, surface_reuse: false, surface_delta: false, + surface_scroll: false, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -358,10 +363,12 @@ mod tests { value.as_object_mut().unwrap().remove("surface_active"); value.as_object_mut().unwrap().remove("surface_reuse"); value.as_object_mut().unwrap().remove("surface_delta"); + value.as_object_mut().unwrap().remove("surface_scroll"); let decoded: EndpointClientHello = serde_json::from_value(value).unwrap(); assert!(decoded.surface_active); assert!(!decoded.surface_reuse); assert!(!decoded.surface_delta); + assert!(!decoded.surface_scroll); } #[test] @@ -372,6 +379,7 @@ mod tests { vec![ super::super::surface_reuse::CAPABILITY.to_string(), super::super::surface_delta::CAPABILITY.to_string(), + super::super::surface_scroll::CAPABILITY.to_string(), SURFACE_INTEREST_CAPABILITY.to_string(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), HEALTH_CHECK_CAPABILITY.to_string(), diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 24e2ebd2..6a3cac7a 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -4,6 +4,7 @@ pub mod endpoint; pub(crate) mod render_ansi; pub(crate) mod surface_delta; pub(crate) mod surface_reuse; +pub(crate) mod surface_scroll; mod wire; pub use wire::*; diff --git a/src/protocol/surface_delta.rs b/src/protocol/surface_delta.rs index 8f94633b..b0abe4da 100644 --- a/src/protocol/surface_delta.rs +++ b/src/protocol/surface_delta.rs @@ -224,7 +224,7 @@ mod tests { } fn reconstruct(last: &PaneSurfaceFrame, next: &PaneSurfaceFrame) -> ServerMessage { - let mut decoder = crate::protocol::surface_reuse::Decoder::new(true); + let mut decoder = crate::protocol::surface_reuse::Decoder::new(true, false); decoder .decode(ServerMessage::PaneSurface(last.clone())) .unwrap(); @@ -384,7 +384,7 @@ mod tests { 8 => corrupt.surface.projection_revision = 0, _ => unreachable!(), } - let mut decoder = crate::protocol::surface_reuse::Decoder::new(true); + let mut decoder = crate::protocol::surface_reuse::Decoder::new(true, false); decoder .decode(ServerMessage::PaneSurface(last.clone())) .unwrap(); @@ -409,7 +409,7 @@ mod tests { let mut next = last.clone(); next.surface_revision += 1; next.frame.cells[0].symbol = "a".into(); - let mut decoder = crate::protocol::surface_reuse::Decoder::new(true); + let mut decoder = crate::protocol::surface_reuse::Decoder::new(true, false); decoder.decode(ServerMessage::PaneSurface(last)).unwrap(); decoder .decode(ServerMessage::PaneSurfacePatch( @@ -512,7 +512,7 @@ mod tests { .decode(ServerMessage::PaneSurface(last.clone())) .unwrap(); assert!(legacy.decode(update.clone()).is_err()); - let mut decoder = crate::protocol::surface_reuse::Decoder::new(true); + let mut decoder = crate::protocol::surface_reuse::Decoder::new(true, false); decoder.decode(ServerMessage::PaneSurface(last)).unwrap(); let ServerMessage::EndpointControl { data, .. } = &update else { panic!("delta"); diff --git a/src/protocol/surface_reuse.rs b/src/protocol/surface_reuse.rs index aa2187d4..ec430492 100644 --- a/src/protocol/surface_reuse.rs +++ b/src/protocol/surface_reuse.rs @@ -53,6 +53,16 @@ struct CellBaseline { popup: Option<PopupBaseline>, } +impl CellBaseline { + /// Whether a patch applies to exactly this baseline as its next revision. + fn matches_patch(&self, patch: &super::PaneSurfacePatch) -> bool { + patch.boot_id == self.boot_id + && patch.projection_revision == self.projection_revision + && patch.base_surface_revision == self.surface_revision + && patch.surface_revision == self.surface_revision.saturating_add(1) + } +} + struct PopupBaseline { terminal_id: String, width: u16, @@ -75,13 +85,15 @@ fn popup_baseline(surface: &PaneSurfaceFrame) -> Option<PopupBaseline> { pub(crate) struct Decoder { baseline: Option<CellBaseline>, surface_delta: bool, + surface_scroll: bool, } impl Decoder { - pub(crate) fn new(surface_delta: bool) -> Self { + pub(crate) fn new(surface_delta: bool, surface_scroll: bool) -> Self { Self { baseline: None, surface_delta, + surface_scroll, } } @@ -95,6 +107,16 @@ impl Decoder { } return self.decode_delta(&data).map(ServerMessage::PaneSurface); } + ServerMessage::EndpointControl { kind, data } + if kind == super::surface_scroll::MESSAGE_KIND => + { + if !self.surface_scroll { + return Err("surface scroll was not negotiated".into()); + } + return self + .decode_scroll(&data) + .map(ServerMessage::PaneSurfacePatch); + } ServerMessage::EndpointControl { kind, data } if kind == MESSAGE_KIND => { let reuse: SurfaceReuse<PaneSurfaceFrame> = serde_json::from_str(&data) .map_err(|error| format!("invalid surface reuse: {error}"))?; @@ -136,11 +158,7 @@ impl Decoder { } ServerMessage::PaneSurfacePatch(patch) => { if let Some(base) = &mut self.baseline { - if patch.boot_id != base.boot_id - || patch.projection_revision != base.projection_revision - || patch.base_surface_revision != base.surface_revision - || patch.surface_revision != base.surface_revision.saturating_add(1) - { + if !base.matches_patch(patch) { self.baseline = None; } else { for row in &patch.rows { @@ -164,6 +182,19 @@ impl Decoder { Ok(message) } + fn decode_scroll(&mut self, data: &str) -> Result<super::PaneSurfacePatch, String> { + let Some(base) = &mut self.baseline else { + return Err("surface scroll without a baseline".into()); + }; + let scroll = super::surface_scroll::decode(data)?; + if !base.matches_patch(&scroll.patch) { + return Err("surface scroll does not match its baseline".into()); + } + let patch = super::surface_scroll::apply(&mut base.cells, base.width, base.height, scroll)?; + base.surface_revision = patch.surface_revision; + Ok(patch) + } + fn decode_delta(&mut self, data: &str) -> Result<PaneSurfaceFrame, String> { use super::surface_delta::{self, GridUpdate}; let Some(base) = &mut self.baseline else { diff --git a/src/protocol/surface_scroll.rs b/src/protocol/surface_scroll.rs new file mode 100644 index 00000000..4e9a85ba --- /dev/null +++ b/src/protocol/surface_scroll.rs @@ -0,0 +1,720 @@ +//! Optional scroll-aware delivery of retained pane surface patches. +//! +//! Scrolling output moves every visible row, so a row diff resends the whole +//! pane on each frame. This codec sends each pane's vertical shift once, plus +//! only the rows that still differ after it. The receiver expands the result +//! back into an ordinary [`PaneSurfacePatch`] against its retained grid. + +use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; + +use super::{ + CellData, FrameData, PaneSurfaceFrame, PaneSurfacePatch, PaneSurfacePatchRow, ServerMessage, + SurfaceRect, MAX_FRAME_SIZE, +}; + +pub(crate) const CAPABILITY: &str = "surface_scroll"; +pub(crate) const MESSAGE_KIND: &str = "endpoint.surface-scroll.v1"; + +// This byte layout is frozen with MESSAGE_KIND: one count byte, then per +// scroll x, y, width, height (u16 LE) and shift (i16 LE), then one framed +// `ServerMessage::PaneSurfacePatch` decoded by the shared bounded reader. +const MAX_SCROLLS: usize = 64; +const SCROLL_BYTES: usize = 10; + +/// Reorders the rows of one pane region before the patch rows apply. +/// +/// A positive `shift` moves content up: row `y` shows the previous row +/// `y + shift`. Rows that scroll out of the region rotate into the vacated +/// rows, so applying a scroll never clones cell content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SurfaceScroll { + pub(crate) rect: SurfaceRect, + pub(crate) shift: i16, +} + +#[derive(Debug)] +pub(crate) struct ScrollPatch { + pub(crate) scrolls: Vec<SurfaceScroll>, + pub(crate) patch: PaneSurfacePatch, +} + +/// The row swaps that define [`SurfaceScroll`]. Both peers must derive the +/// same row order, so encoder and decoder share this one sequence. +fn for_each_swap(height: usize, shift: i16, mut swap: impl FnMut(usize, usize)) { + let distance = usize::from(shift.unsigned_abs()); + if shift > 0 { + for y in 0..height - distance { + swap(y, y + distance); + } + } else { + for y in (distance..height).rev() { + swap(y, y - distance); + } + } +} + +fn scroll_fits(scroll: &SurfaceScroll, width: u16, height: u16) -> bool { + let rect = scroll.rect; + rect.width > 0 + && rect.height >= 2 + && scroll.shift != 0 + && scroll.shift.unsigned_abs() < rect.height + && rect + .x + .checked_add(rect.width) + .is_some_and(|end| end <= width) + && rect + .y + .checked_add(rect.height) + .is_some_and(|end| end <= height) +} + +fn rects_overlap(a: SurfaceRect, b: SurfaceRect) -> bool { + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height +} + +/// Pane regions never overlap. Requiring that bounds expansion to one copy of the grid. +fn scrolls_disjoint(scrolls: &[SurfaceScroll]) -> bool { + scrolls.iter().enumerate().all(|(index, scroll)| { + scrolls[index + 1..] + .iter() + .all(|other| !rects_overlap(scroll.rect, other.rect)) + }) +} + +fn row_fits(row: &PaneSurfacePatchRow, width: u16, height: u16) -> bool { + row.y < height && usize::from(row.x).saturating_add(row.cells.len()) <= usize::from(width) +} + +/// Applies one validated scroll to a `width`-wide row-major grid. +fn apply_scroll(cells: &mut [CellData], width: u16, scroll: &SurfaceScroll) { + let rect = scroll.rect; + let stride = usize::from(width); + let row_start = |y: usize| (usize::from(rect.y) + y) * stride + usize::from(rect.x); + for_each_swap(usize::from(rect.height), scroll.shift, |a, b| { + let (a, b) = (row_start(a), row_start(b)); + for x in 0..usize::from(rect.width) { + cells.swap(a + x, b + x); + } + }); +} + +/// One pane row as the patch leaves it: committed cells overlaid by the patch +/// spans that touch the row, read in place so detection copies nothing. +struct RowView<'a> { + base: &'a [CellData], + left: usize, + spans: Vec<&'a PaneSurfacePatchRow>, +} + +impl RowView<'_> { + fn cell(&self, x: usize) -> &CellData { + let column = self.left + x; + for span in self.spans.iter().rev() { + let start = usize::from(span.x); + if column >= start && column < start + span.cells.len() { + return &span.cells[column - start]; + } + } + &self.base[x] + } + + fn hash(&self) -> u64 { + (0..self.base.len()).fold(0, |hash, x| cell_hash(hash, self.cell(x))) + } +} + +// Row fingerprints only nominate a shift; the residual diff compares real +// cells, so a collision can cost bytes but never correctness. +fn mix(hash: u64, value: u64) -> u64 { + (hash.rotate_left(5) ^ value).wrapping_mul(0x517c_c1b7_2722_0a95) +} + +fn cell_hash(hash: u64, cell: &CellData) -> u64 { + let hash = cell + .symbol + .bytes() + .fold(hash, |hash, byte| mix(hash, u64::from(byte))); + let hash = mix(hash, u64::from(cell.fg) << 32 | u64::from(cell.bg)); + mix( + hash, + u64::from(cell.modifier) + | u64::from(cell.skip) << 16 + | cell.hyperlink.map_or(0, |index| u64::from(index) + 1) << 17, + ) +} + +fn fully_inside(row: &PaneSurfacePatchRow, rect: SurfaceRect) -> bool { + row.y >= rect.y + && row.y - rect.y < rect.height + && row.x >= rect.x + && usize::from(row.x) + row.cells.len() <= usize::from(rect.x) + usize::from(rect.width) +} + +/// Finds the shift that best explains one pane's patch and returns it with the +/// rows that still differ afterwards, or `None` when the shift would not at +/// least halve the cells this pane's patch carries. +fn pane_scroll( + frame: &FrameData, + rect: SurfaceRect, + rows: &[PaneSurfacePatchRow], +) -> Option<(i16, Vec<PaneSurfacePatchRow>)> { + let (width, height) = (usize::from(rect.width), usize::from(rect.height)); + let stride = usize::from(frame.width); + let previous = |y: usize| { + let start = (usize::from(rect.y) + y) * stride + usize::from(rect.x); + &frame.cells[start..start + width] + }; + + let mut next = (0..height) + .map(|y| RowView { + base: previous(y), + left: usize::from(rect.x), + spans: Vec::new(), + }) + .collect::<Vec<_>>(); + let mut patch_cells = 0; + for row in rows { + let overlaps = row.y >= rect.y + && row.y - rect.y < rect.height + && usize::from(row.x) < usize::from(rect.x) + width + && usize::from(row.x) + row.cells.len() > usize::from(rect.x); + if overlaps { + next[usize::from(row.y - rect.y)].spans.push(row); + if fully_inside(row, rect) { + patch_cells += row.cells.len(); + } + } + } + // A shift can only beat the plain patch when at least two rows changed. + if next.iter().filter(|row| !row.spans.is_empty()).count() < 2 { + return None; + } + + let old_hashes = next + .iter() + .map(|row| row.base.iter().fold(0, cell_hash)) + .collect::<Vec<_>>(); + let new_hashes = next + .iter() + .zip(&old_hashes) + .map(|(row, &old)| { + if row.spans.is_empty() { + old + } else { + row.hash() + } + }) + .collect::<Vec<_>>(); + let matches = |shift: isize| { + (0..height) + .filter(|&y| { + let source = y as isize + shift; + source >= 0 + && (source as usize) < height + && new_hashes[y] == old_hashes[source as usize] + }) + .count() + }; + let unshifted = matches(0); + let (mut best_shift, mut best) = (0isize, unshifted); + for distance in 1..height { + if height - distance <= best { + break; + } + for shift in [distance as isize, -(distance as isize)] { + let count = matches(shift); + if count > best { + (best_shift, best) = (shift, count); + } + } + } + if best_shift == 0 { + return None; + } + let shift = i16::try_from(best_shift).ok()?; + + let mut order = (0..height).collect::<Vec<_>>(); + for_each_swap(height, shift, |a, b| order.swap(a, b)); + let mut residual = Vec::new(); + let mut residual_cells = 0; + for (y, &source) in order.iter().enumerate() { + let (base, target) = (previous(source), &next[y]); + let mut x = 0; + while x < width { + if base[x] == *target.cell(x) { + x += 1; + continue; + } + let start = x; + while x < width && base[x] != *target.cell(x) { + x += 1; + } + residual_cells += x - start; + if residual_cells * 2 >= patch_cells { + return None; + } + residual.push(PaneSurfacePatchRow { + x: rect.x + start as u16, + y: rect.y + y as u16, + cells: (start..x).map(|x| target.cell(x).clone()).collect(), + }); + } + } + Some((shift, residual)) +} + +fn encoded_size(value: &impl serde::Serialize) -> Option<usize> { + let mut writer = bincode::enc::write::SizeWriter::default(); + bincode::serde::encode_into_writer(value, &mut writer, bincode::config::standard()).ok()?; + Some(writer.bytes_written) +} + +/// Encodes `patch` against the committed `last` surface as a scroll message +/// when some pane's shift at least halves its patched cells. Returns `None` +/// otherwise, so ordinary edits never pay for the wrapper. +pub(crate) fn message(last: &PaneSurfaceFrame, patch: &PaneSurfacePatch) -> Option<ServerMessage> { + let frame = &last.frame; + if frame.cells.len() != usize::from(frame.width) * usize::from(frame.height) { + return None; + } + let mut scrolls = Vec::new(); + let mut residual = Vec::new(); + for pane in &patch.panes { + let rect = pane.inner_rect; + // Both peers hold the committed geometry; never scroll a region that moved. + let committed = last + .panes + .iter() + .any(|existing| existing.pane_id == pane.pane_id && existing.inner_rect == rect); + let scroll = SurfaceScroll { rect, shift: 1 }; + if !committed || !scroll_fits(&scroll, frame.width, frame.height) { + continue; + } + if let Some((shift, rows)) = pane_scroll(frame, rect, &patch.rows) { + scrolls.push(SurfaceScroll { rect, shift }); + residual.extend(rows); + if scrolls.len() == MAX_SCROLLS { + break; + } + } + } + if scrolls.is_empty() { + return None; + } + + // Rows that straddle a scrolled region keep their final values, so they + // stay in the patch and apply after the scroll. + let rows = patch + .rows + .iter() + .filter(|row| !scrolls.iter().any(|scroll| fully_inside(row, scroll.rect))) + .cloned() + .chain(residual) + .collect(); + let inner = ServerMessage::PaneSurfacePatch(PaneSurfacePatch { + boot_id: patch.boot_id.clone(), + projection_revision: patch.projection_revision, + base_surface_revision: patch.base_surface_revision, + surface_revision: patch.surface_revision, + rows, + panes: patch.panes.clone(), + cursor: patch.cursor.clone(), + }); + let mut bytes = Vec::with_capacity(1 + scrolls.len() * SCROLL_BYTES); + bytes.push(scrolls.len() as u8); + for scroll in &scrolls { + let rect = scroll.rect; + for value in [rect.x, rect.y, rect.width, rect.height] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.extend_from_slice(&scroll.shift.to_le_bytes()); + } + super::write_message(&mut bytes, &inner).ok()?; + let message = ServerMessage::EndpointControl { + kind: MESSAGE_KIND.into(), + data: STANDARD_NO_PAD.encode(bytes), + }; + (encoded_size(&message)? <= MAX_FRAME_SIZE).then_some(message) +} + +/// Decodes one scroll message. Geometry is validated against the receiver's +/// baseline by [`apply`], never trusted here. +pub(crate) fn decode(data: &str) -> Result<ScrollPatch, String> { + let limit = base64::encoded_len(MAX_FRAME_SIZE, false).unwrap_or(usize::MAX); + if data.len() > limit { + return Err("surface scroll exceeds the frame limit".into()); + } + let bytes = STANDARD_NO_PAD + .decode(data) + .map_err(|error| format!("invalid surface scroll: {error}"))?; + let count = usize::from(*bytes.first().ok_or("empty surface scroll")?); + if count == 0 || count > MAX_SCROLLS { + return Err("surface scroll has an invalid scroll count".into()); + } + let header = 1 + count * SCROLL_BYTES; + if bytes.len() < header { + return Err("surface scroll is truncated".into()); + } + let scrolls = bytes[1..header] + .chunks_exact(SCROLL_BYTES) + .map(|chunk| { + let value = |at: usize| u16::from_le_bytes([chunk[at], chunk[at + 1]]); + SurfaceScroll { + rect: SurfaceRect { + x: value(0), + y: value(2), + width: value(4), + height: value(6), + }, + shift: i16::from_le_bytes([chunk[8], chunk[9]]), + } + }) + .collect(); + let mut frame = &bytes[header..]; + let decoded = super::read_message::<_, ServerMessage>(&mut frame, MAX_FRAME_SIZE); + if !frame.is_empty() { + return Err("surface scroll has trailing bytes".into()); + } + match decoded { + Ok(ServerMessage::PaneSurfacePatch(patch)) => Ok(ScrollPatch { scrolls, patch }), + Ok(_) => Err("surface scroll does not carry a pane patch".into()), + Err(error) => Err(format!("invalid surface scroll patch: {error}")), + } +} + +/// Applies a decoded scroll patch to a retained grid and returns the ordinary +/// patch a receiver without scroll support would have needed. The grid is +/// untouched when validation fails. +pub(crate) fn apply( + cells: &mut [CellData], + width: u16, + height: u16, + scroll_patch: ScrollPatch, +) -> Result<PaneSurfacePatch, String> { + let ScrollPatch { scrolls, mut patch } = scroll_patch; + if cells.len() != usize::from(width) * usize::from(height) { + return Err("surface scroll baseline has an invalid grid".into()); + } + if !scrolls + .iter() + .all(|scroll| scroll_fits(scroll, width, height)) + || !scrolls_disjoint(&scrolls) + || !patch.rows.iter().all(|row| row_fits(row, width, height)) + { + return Err("surface scroll exceeds the cell baseline".into()); + } + for scroll in &scrolls { + apply_scroll(cells, width, scroll); + } + for row in &patch.rows { + let start = usize::from(row.y) * usize::from(width) + usize::from(row.x); + cells[start..start + row.cells.len()].clone_from_slice(&row.cells); + } + // Each scrolled row is emitted once with its final cells, so the expanded patch keeps + // only the parts of other rows outside every region. Receivers require disjoint rows. + let mut rows = Vec::with_capacity(patch.rows.len()); + for row in std::mem::take(&mut patch.rows) { + rows.extend(outside_scrolls(row, &scrolls)); + } + for scroll in &scrolls { + let rect = scroll.rect; + for y in rect.y..rect.y + rect.height { + let start = usize::from(y) * usize::from(width) + usize::from(rect.x); + rows.push(PaneSurfacePatchRow { + x: rect.x, + y, + cells: cells[start..start + usize::from(rect.width)].to_vec(), + }); + } + } + patch.rows = rows; + Ok(patch) +} + +/// The spans of `row` not covered by any scrolled region. +fn outside_scrolls( + row: PaneSurfacePatchRow, + scrolls: &[SurfaceScroll], +) -> Vec<PaneSurfacePatchRow> { + let start = usize::from(row.x); + let mut covered = scrolls + .iter() + .map(|scroll| scroll.rect) + .filter(|rect| row.y >= rect.y && row.y - rect.y < rect.height) + .map(|rect| { + ( + usize::from(rect.x), + usize::from(rect.x) + usize::from(rect.width), + ) + }) + .collect::<Vec<_>>(); + if covered.is_empty() { + return vec![row]; + } + covered.sort_unstable(); + let end = start + row.cells.len(); + let mut spans = Vec::new(); + let mut cursor = start; + for (left, right) in covered.into_iter().chain([(end, end)]) { + let span_end = left.clamp(cursor, end); + if span_end > cursor { + spans.push(PaneSurfacePatchRow { + x: cursor as u16, + y: row.y, + cells: row.cells[cursor - start..span_end - start].to_vec(), + }); + } + cursor = cursor.max(right.min(end)); + } + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{FrameData, PaneSurfacePane}; + use ratatui::{buffer::Buffer, layout::Rect}; + + const WIDTH: u16 = 30; + const HEIGHT: u16 = 12; + const PANE: SurfaceRect = SurfaceRect { + x: 2, + y: 1, + width: 20, + height: 10, + }; + + fn pane() -> PaneSurfacePane { + PaneSurfacePane { + pane_id: "w1:p1".into(), + content_revision: 1, + rect: SurfaceRect { + x: 1, + y: 0, + width: 22, + height: 12, + }, + inner_rect: PANE, + scrollbar_rect: None, + scroll: None, + focused: true, + mouse_reporting: false, + sgr_pixel_mouse: false, + alternate_screen_active: false, + pixel_width: 0, + pixel_height: 0, + } + } + + fn surface() -> PaneSurfaceFrame { + let mut frame = + FrameData::from_ratatui_buffer(&Buffer::empty(Rect::new(0, 0, WIDTH, HEIGHT)), None); + for y in 0..PANE.height { + write(&mut frame, y, &line(i32::from(y))); + } + PaneSurfaceFrame { + boot_id: "boot".into(), + projection_revision: 1, + surface_revision: 1, + frame, + panes: vec![pane()], + splits: Vec::new(), + popup: None, + graphics: Default::default(), + } + } + + /// Distinct output lines, like a log or build scrolling past. + fn line(n: i32) -> String { + let word = ["alpha", "bravo", "charlie", "delta", "echo"][n.rem_euclid(5) as usize]; + format!("{n:>3} {word} {}", word.to_uppercase()) + } + + fn write(frame: &mut FrameData, pane_row: u16, text: &str) { + let start = usize::from(PANE.y + pane_row) * usize::from(WIDTH) + usize::from(PANE.x); + for (x, cell) in frame.cells[start..start + usize::from(PANE.width)] + .iter_mut() + .enumerate() + { + cell.symbol = text.chars().nth(x).map_or(" ".into(), String::from); + } + } + + /// The changed-cell spans the retained renderer would publish for `next`. + fn row_patch(last: &PaneSurfaceFrame, next: &FrameData) -> PaneSurfacePatch { + let mut rows = Vec::new(); + for y in 0..HEIGHT { + let start = usize::from(y) * usize::from(WIDTH); + let (old, new) = ( + &last.frame.cells[start..start + usize::from(WIDTH)], + &next.cells[start..start + usize::from(WIDTH)], + ); + let mut x = 0; + while x < old.len() { + if old[x] == new[x] { + x += 1; + continue; + } + let first = x; + while x < old.len() && old[x] != new[x] { + x += 1; + } + rows.push(PaneSurfacePatchRow { + x: first as u16, + y, + cells: new[first..x].to_vec(), + }); + } + } + PaneSurfacePatch { + boot_id: last.boot_id.clone(), + projection_revision: last.projection_revision, + base_surface_revision: last.surface_revision, + surface_revision: last.surface_revision + 1, + rows, + panes: vec![pane()], + cursor: None, + } + } + + fn round_trip(last: &PaneSurfaceFrame, patch: &PaneSurfacePatch) -> (usize, FrameData) { + let message = message(last, patch).expect("scroll message"); + let size = encoded_size(&message).unwrap(); + let ServerMessage::EndpointControl { kind, data } = message else { + panic!("scroll must be an endpoint control frame"); + }; + assert_eq!(kind, MESSAGE_KIND); + let mut cells = last.frame.cells.clone(); + let expanded = apply(&mut cells, WIDTH, HEIGHT, decode(&data).unwrap()).unwrap(); + // A receiver without scroll support must reach the same grid. + let mut plain = last.frame.clone(); + for row in &expanded.rows { + let start = usize::from(row.y) * usize::from(WIDTH) + usize::from(row.x); + plain.cells[start..start + row.cells.len()].clone_from_slice(&row.cells); + } + assert_eq!(plain.cells, cells); + assert_disjoint(&expanded.rows); + (size, plain) + } + + /// Receivers only take the fast presentation path for non-overlapping rows. + fn assert_disjoint(rows: &[PaneSurfacePatchRow]) { + for (index, a) in rows.iter().enumerate() { + for b in &rows[index + 1..] { + let overlap = a.y == b.y + && usize::from(a.x) < usize::from(b.x) + b.cells.len() + && usize::from(b.x) < usize::from(a.x) + a.cells.len(); + assert!(!overlap, "expanded rows overlap: {a:?} / {b:?}"); + } + } + } + + #[test] + fn rows_crossing_a_scrolled_region_keep_only_their_outside_cells() { + let last = surface(); + let mut next = last.frame.clone(); + for y in 0..PANE.height { + write(&mut next, y, &line(i32::from(y) + 1)); + } + let mut patch = row_patch(&last, &next); + // One full-width row that runs across the pane, as a border repaint would. + let y = PANE.y + 4; + let start = usize::from(y) * usize::from(WIDTH); + patch.rows.push(PaneSurfacePatchRow { + x: 0, + y, + cells: next.cells[start..start + usize::from(WIDTH)].to_vec(), + }); + let (_, result) = round_trip(&last, &patch); + assert_eq!(result.cells, next.cells); + } + + #[test] + fn scrolling_output_sends_the_shift_and_only_new_rows() { + let last = surface(); + let mut next = last.frame.clone(); + for y in 0..PANE.height { + write(&mut next, y, &line(i32::from(y) + 1)); + } + let patch = row_patch(&last, &next); + + let (size, result) = round_trip(&last, &patch); + assert_eq!(result.cells, next.cells); + let plain = encoded_size(&patch).unwrap(); + assert!( + size * 3 < plain, + "scroll must be much smaller: {size} vs {plain}" + ); + } + + #[test] + fn reverse_scroll_and_edits_reach_the_same_grid() { + let last = surface(); + let mut next = last.frame.clone(); + for y in 0..PANE.height { + write(&mut next, y, &line(i32::from(y) - 2)); + } + write(&mut next, 5, "edited in place"); + let (_, result) = round_trip(&last, &row_patch(&last, &next)); + assert_eq!(result.cells, next.cells); + } + + #[test] + fn a_single_row_change_stays_a_plain_patch() { + let last = surface(); + let mut next = last.frame.clone(); + write(&mut next, 9, "typed"); + assert!(message(&last, &row_patch(&last, &next)).is_none()); + } + + #[test] + fn unrelated_rewrites_stay_a_plain_patch() { + let last = surface(); + let mut next = last.frame.clone(); + for y in 0..PANE.height { + write(&mut next, y, &format!("other {y}")); + } + assert!(message(&last, &row_patch(&last, &next)).is_none()); + } + + #[test] + fn decode_rejects_regions_outside_the_baseline() { + let last = surface(); + let mut next = last.frame.clone(); + for y in 0..PANE.height { + write(&mut next, y, &line(i32::from(y) + 1)); + } + let Some(ServerMessage::EndpointControl { data, .. }) = + message(&last, &row_patch(&last, &next)) + else { + panic!("scroll message"); + }; + let mut cells = last.frame.cells.clone(); + let mut decoded = decode(&data).unwrap(); + decoded.scrolls[0].rect.height = HEIGHT; + assert!(apply(&mut cells, WIDTH, HEIGHT, decoded).is_err()); + assert_eq!( + cells, last.frame.cells, + "a rejected scroll must not touch the grid" + ); + + let mut decoded = decode(&data).unwrap(); + decoded.scrolls.push(decoded.scrolls[0]); + assert!( + apply(&mut cells, WIDTH, HEIGHT, decoded).is_err(), + "overlapping regions" + ); + assert_eq!(cells, last.frame.cells); + + let mut decoded = decode(&data).unwrap(); + decoded.scrolls[0].shift = PANE.height as i16; + assert!(apply(&mut cells, WIDTH, HEIGHT, decoded).is_err()); + let mut trailing = STANDARD_NO_PAD.decode(&data).unwrap(); + trailing.push(0); + assert!(decode(&STANDARD_NO_PAD.encode(trailing)).is_err()); + assert!(decode("").is_err()); + assert!(decode(&STANDARD_NO_PAD.encode([0u8])).is_err()); + assert!(decode(&STANDARD_NO_PAD.encode([1u8, 0, 0])).is_err()); + } +} diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 465408f5..88daf250 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -418,6 +418,7 @@ pub(crate) enum ServerEvent { surface_active: bool, surface_reuse: bool, surface_delta: bool, + surface_scroll: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -785,6 +786,7 @@ pub(crate) fn handle_client_handshake( hello.surface_active, hello.surface_reuse, hello.surface_delta, + hello.surface_scroll, )), ) } @@ -881,6 +883,7 @@ pub(crate) fn handle_client_handshake( surface_active, surface_reuse, surface_delta, + surface_scroll, )) = shell_options { ServerEvent::ClientShellConnected { @@ -896,6 +899,7 @@ pub(crate) fn handle_client_handshake( surface_active, surface_reuse, surface_delta, + surface_scroll, writer, } } else { @@ -1472,6 +1476,7 @@ mod tests { surface_active: true, surface_reuse: false, surface_delta: false, + surface_scroll: false, snapshot_codecs: vec![crate::protocol::endpoint::SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![crate::protocol::endpoint::SURFACE_CODEC_V1.into()], input_codecs: vec![crate::protocol::endpoint::INPUT_CODEC_V1.into()], @@ -1989,10 +1994,12 @@ mod tests { surface_active, surface_reuse, surface_delta, + surface_scroll, writer, } => { assert!(!surface_reuse); assert!(!surface_delta); + assert!(!surface_scroll); assert_eq!(client_id, 43); assert_eq!((surface_cols, surface_rows), (80, 29)); assert_eq!((cell_width_px, cell_height_px), (8, 16)); diff --git a/src/server/headless.rs b/src/server/headless.rs index da3baf30..18869e09 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1861,6 +1861,7 @@ impl HeadlessServer { surface_active, surface_reuse, surface_delta, + surface_scroll, writer, } => { if self.handoff_in_progress { @@ -1908,6 +1909,9 @@ impl HeadlessServer { connection.shell_surface_active = surface_active; connection.render_state.enable_surface_reuse(surface_reuse); connection.render_state.enable_surface_delta(surface_delta); + connection + .render_state + .enable_surface_scroll(surface_scroll); connection.shell_projection_revision = 1; let config_diagnostic = if endpoint_keybindings { self.server_config_diagnostic.as_deref() diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 2dd082d6..27f3bfff 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -10,6 +10,8 @@ mod retained_graphics_tests; mod surface_delta_tests; #[path = "surface_interest.rs"] mod surface_interest_tests; +#[path = "surface_scroll.rs"] +mod surface_scroll_tests; fn client_shell_projection( receiver: &std::sync::mpsc::Receiver<Vec<u8>>, @@ -719,6 +721,7 @@ async fn client_shell_attach_seeds_workspace() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 6, surface_cols: 80, surface_rows: 23, @@ -761,6 +764,7 @@ async fn completion_guard_endpoint_pairs_runtime_completions_with_snapshots() { surface_active: false, surface_reuse: false, surface_delta: false, + surface_scroll: false, writer, }); let (_, initial) = client_shell_projection(&control_rx); @@ -809,6 +813,7 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id, surface_cols: 80, surface_rows: 23, @@ -927,6 +932,7 @@ async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots( server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 77, surface_cols: 80, surface_rows: 23, @@ -1029,6 +1035,7 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 7, surface_cols: 80, surface_rows: 23, @@ -1196,6 +1203,7 @@ fn connect_test_shell( server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id, surface_cols, surface_rows, @@ -1800,6 +1808,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 13, surface_cols: 80, surface_rows: 23, @@ -1824,6 +1833,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 14, surface_cols: 80, surface_rows: 23, @@ -2743,6 +2753,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 9, surface_cols: 80, surface_rows: 23, @@ -2989,6 +3000,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 12, surface_cols: 80, surface_rows: 23, diff --git a/src/server/headless/tests/surface_delta.rs b/src/server/headless/tests/surface_delta.rs index f010c1e3..996b5b1b 100644 --- a/src/server/headless/tests/surface_delta.rs +++ b/src/server/headless/tests/surface_delta.rs @@ -51,7 +51,7 @@ async fn surface_delta_reconstructs_metadata_text_and_hyperlinks() { enable_delta(&mut server, 1); server.render_and_stream(); let (initial_bytes, initial_message) = receive_message(&render_rx); - let mut decoder = protocol::surface_reuse::Decoder::new(true); + let mut decoder = protocol::surface_reuse::Decoder::new(true, false); let initial = decode_surface_message(&mut decoder, initial_message); assert_eq!( without_asset_payload(initial.clone()), @@ -119,7 +119,7 @@ async fn surface_delta_updates_global_popup_without_affecting_legacy_peer() { server.render_and_stream(); let (initial_bytes, initial_message) = receive_message(&delta_render); let (_, _legacy_initial) = receive_message(&legacy_render); - let mut decoder = protocol::surface_reuse::Decoder::new(true); + let mut decoder = protocol::surface_reuse::Decoder::new(true, false); let _ = decode_surface_message(&mut decoder, initial_message); server @@ -160,7 +160,7 @@ async fn surface_delta_preserves_graphics_baseline_through_queue_recovery() { enable_delta(&mut server, 1); server.render_and_stream(); let (initial_bytes, initial_message) = receive_message(&render_rx); - let mut decoder = protocol::surface_reuse::Decoder::new(true); + let mut decoder = protocol::surface_reuse::Decoder::new(true, false); let _ = decode_surface_message(&mut decoder, initial_message); write_shared_test_pane( diff --git a/src/server/headless/tests/surface_interest.rs b/src/server/headless/tests/surface_interest.rs index f3acc27b..a7da046e 100644 --- a/src/server/headless/tests/surface_interest.rs +++ b/src/server/headless/tests/surface_interest.rs @@ -66,6 +66,7 @@ async fn metadata_only_shell_is_isolated_until_surface_activation() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id, surface_cols: 101, surface_rows: 37, @@ -281,6 +282,7 @@ async fn background_surface_activation_preserves_focused_viewer_geometry() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: 8, surface_cols: 100, surface_rows: 35, @@ -394,6 +396,7 @@ async fn presentation_sync_epoch_replays_modes_and_title() { server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id, surface_cols: 80, surface_rows: 24, @@ -510,6 +513,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { source_server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: source_client_id, surface_cols: 80, surface_rows: 24, @@ -533,6 +537,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { target_server.handle_server_event(ServerEvent::ClientShellConnected { surface_reuse: false, surface_delta: false, + surface_scroll: false, client_id: target_client_id, surface_cols: 80, surface_rows: 24, diff --git a/src/server/headless/tests/surface_scroll.rs b/src/server/headless/tests/surface_scroll.rs new file mode 100644 index 00000000..8975650b --- /dev/null +++ b/src/server/headless/tests/surface_scroll.rs @@ -0,0 +1,89 @@ +use super::*; + +fn scrolling_lines(range: std::ops::Range<usize>) -> Vec<u8> { + range + .map(|n| { + format!( + "build step {n:>3}: compiled module_{n} in {}ms\r\n", + n * 7 % 97 + ) + }) + .collect::<String>() + .into_bytes() +} + +fn apply_rows(frame: &mut FrameData, rows: &[crate::protocol::PaneSurfacePatchRow]) { + for row in rows { + let start = usize::from(row.y) * usize::from(frame.width) + usize::from(row.x); + frame.cells[start..start + row.cells.len()].clone_from_slice(&row.cells); + } +} + +#[tokio::test] +async fn surface_scroll_sends_scrolling_output_as_a_shift_and_new_rows() { + let (mut server, _control_rx, render_rx, pane_id) = + retained_test_server_with_control(&scrolling_lines(0..40)); + server + .clients + .get_mut(&1) + .expect("scroll client") + .render_state + .enable_surface_scroll(true); + server.render_and_stream(); + let mut decoder = protocol::surface_reuse::Decoder::new(false, true); + let ServerMessage::PaneSurface(mut shell) = decoder + .decode(read_server_message( + render_rx.recv_timeout(Duration::from_secs(1)).unwrap(), + )) + .expect("initial surface") + else { + panic!("expected the initial pane surface"); + }; + + write_shared_test_pane(&mut server, pane_id, &scrolling_lines(40..42)); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let bytes = render_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + let message = read_server_message(bytes.clone()); + assert!(matches!( + &message, + ServerMessage::EndpointControl { kind, .. } if kind == protocol::surface_scroll::MESSAGE_KIND + )); + + // The decoder hands the client shell an ordinary patch that reaches the + // server's committed surface exactly. + let ServerMessage::PaneSurfacePatch(patch) = decoder.decode(message).expect("scroll decode") + else { + panic!("expected an expanded pane patch"); + }; + apply_rows(&mut shell.frame, &patch.rows); + let committed = server.clients[&1] + .render_state + .last_pane_surface() + .expect("committed surface"); + assert_eq!(shell.frame.cells, committed.frame.cells); + assert!(frame_text(&shell.frame).contains("module_41")); + + let expanded = HeadlessServer::frame_server_message(&ServerMessage::PaneSurfacePatch(patch)) + .expect("expanded frame"); + assert!( + bytes.len() * 4 < expanded.len(), + "scroll frame {} should be far smaller than the rows it replaces {}", + bytes.len(), + expanded.len() + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn surface_scroll_is_not_sent_to_a_peer_that_did_not_negotiate_it() { + let (mut server, _control_rx, render_rx, pane_id) = + retained_test_server_with_control(&scrolling_lines(0..40)); + server.render_and_stream(); + let _ = render_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + write_shared_test_pane(&mut server, pane_id, &scrolling_lines(40..42)); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + let message = read_server_message(render_rx.recv_timeout(Duration::from_secs(1)).unwrap()); + assert!(matches!(message, ServerMessage::PaneSurfacePatch(_))); + shutdown_test_runtimes(&mut server); +} diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index f4bb310b..754c9dbc 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -376,7 +376,8 @@ fn surface_encoding_client(mode: &str, surface: &PaneSurfaceFrame) -> SurfaceEnc state: super::render_stream::ClientRenderState::new( crate::protocol::RenderEncoding::SemanticFrame, ), - decoder: (reuse || delta).then(|| crate::protocol::surface_reuse::Decoder::new(delta)), + decoder: (reuse || delta) + .then(|| crate::protocol::surface_reuse::Decoder::new(delta, false)), }; client.state.enable_surface_reuse(reuse); client.state.enable_surface_delta(delta); @@ -588,3 +589,39 @@ async fn render_scale_profile() { print_surface_reuse_profiles(); print_surface_damage_profiles(); } + +/// Dirty-row collection while output scrolls a large pane, the host's hot path +/// for streaming agents and build logs. +#[tokio::test(flavor = "current_thread")] +#[ignore = "manual dirty collection profile"] +async fn dirty_scroll_collection_profile() { + const WIDTH: u16 = 295; + const HEIGHT: u16 = 84; + let runtime = TerminalRuntime::test_with_scrollback_bytes(WIDTH, HEIGHT, 1024 * 1024, b""); + let mut samples = Vec::new(); + let mut rows = 0; + for line in 0..2_000u32 { + runtime.test_process_pty_bytes( + format!("{:010}{:010}\r\n", line.wrapping_mul(2_654_435_761), line).as_bytes(), + ); + let started = Instant::now(); + let snapshot = black_box(runtime.collect_dirty_patch_snapshot(WIDTH, HEIGHT)); + if line as usize >= WARMUP_COUNT { + samples.push(started.elapsed()); + } + if let Some(crate::pane::TerminalDirtyPatchOutcome::Patch(patch)) = + snapshot.map(|snapshot| snapshot.patch) + { + rows += patch.rows.len(); + } + } + samples.sort_unstable(); + let at = |fraction: f64| samples[((samples.len() - 1) as f64 * fraction) as usize].as_micros(); + println!( + "dirty scroll collection {WIDTH}x{HEIGHT}: median {}us p95 {}us max {}us, {} rows per update", + at(0.5), + at(0.95), + at(1.0), + rows / 2_000 + ); +} diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index ee874e52..aa1bbabe 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -19,6 +19,7 @@ pub(crate) enum ClientRenderState { surface_revision: u64, surface_reuse: bool, surface_delta: bool, + surface_scroll: bool, recompute_pending: bool, }, /// Terminal-ANSI clients keep a terminal diff encoder and sequence number. @@ -37,6 +38,7 @@ impl ClientRenderState { surface_revision: 0, surface_reuse: false, surface_delta: false, + surface_scroll: false, recompute_pending: false, }, RenderEncoding::TerminalAnsi => Self::TerminalAnsi { @@ -59,6 +61,12 @@ impl ClientRenderState { } } + pub(crate) fn enable_surface_scroll(&mut self, enabled: bool) { + if let Self::Semantic { surface_scroll, .. } = self { + *surface_scroll = enabled; + } + } + pub(crate) fn request_recompute(&mut self) { if let Self::Semantic { surface_delta: true, @@ -171,6 +179,7 @@ impl ClientRenderState { surface_reuse, surface_delta, recompute_pending, + .. } = self else { return None; @@ -239,6 +248,7 @@ impl ClientRenderState { let Self::Semantic { last_surface, surface_revision, + surface_scroll, .. } = self else { @@ -256,8 +266,18 @@ impl ClientRenderState { } let next_revision = surface_revision.saturating_add(1); patch.surface_revision = next_revision; - Some(PreparedRender::SemanticPatch { - message: ServerMessage::PaneSurfacePatch(patch), + let scrolled = (*surface_scroll) + .then(|| crate::protocol::surface_scroll::message(last, &patch)) + .flatten(); + Some(match scrolled { + Some(message) => PreparedRender::SemanticPatch { + message, + encoded: Some(Box::new(patch)), + }, + None => PreparedRender::SemanticPatch { + message: ServerMessage::PaneSurfacePatch(patch), + encoded: None, + }, }) } @@ -284,10 +304,13 @@ impl ClientRenderState { surface_revision, .. }, - PreparedRender::SemanticPatch { - message: ServerMessage::PaneSurfacePatch(patch), - }, + PreparedRender::SemanticPatch { message, encoded }, ) => { + let patch = match (encoded, message) { + (Some(patch), _) => *patch, + (None, ServerMessage::PaneSurfacePatch(patch)) => patch, + (None, _) => unreachable!("a plain semantic patch carries its pane patch"), + }; let surface = last_surface .as_deref_mut() .expect("prepared patch baseline"); @@ -358,6 +381,8 @@ pub(crate) enum PreparedRender { }, SemanticPatch { message: ServerMessage, + /// The pane patch a compact `message` encodes; `None` when `message` is that patch. + encoded: Option<Box<PaneSurfacePatch>>, }, TerminalAnsi { message: ServerMessage, @@ -370,7 +395,7 @@ impl PreparedRender { pub(crate) fn message(&self) -> &ServerMessage { match self { Self::Semantic { message, .. } - | Self::SemanticPatch { message } + | Self::SemanticPatch { message, .. } | Self::TerminalAnsi { message, .. } => message, } } From 267d47fb571c0a5e771f2e31bdf6459d625d7459 Mon Sep 17 00:00:00 2001 From: kangal-bot <285672167+kangal-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:48:11 +0000 Subject: [PATCH 89/99] docs: publish preview documentation --- distribution/preview.json | 84 +++++++------ .../src/content/docs/add-herdr-support.mdx | 117 ++++++++++++++++++ .../src/content/docs/agent-automation.mdx | 2 + .../website/src/content/docs/agents.mdx | 16 ++- .../src/content/docs/cli-reference.mdx | 37 +++++- .../src/content/docs/configuration.mdx | 30 ++++- .../src/content/docs/connecting-machines.mdx | 30 +++-- .../website/src/content/docs/install.mdx | 2 + .../website/src/content/docs/integrations.mdx | 35 ++---- .../src/content/docs/ja/add-herdr-support.mdx | 117 ++++++++++++++++++ .../website/src/content/docs/ja/agents.mdx | 10 +- .../src/content/docs/ja/cli-reference.mdx | 12 +- .../src/content/docs/ja/configuration.mdx | 3 + .../content/docs/ja/connecting-machines.mdx | 12 +- .../src/content/docs/ja/integrations.mdx | 35 ++---- .../content/docs/ja/persistence-remote.mdx | 2 +- .../src/content/docs/ja/session-state.mdx | 2 +- .../src/content/docs/ja/socket-api.mdx | 33 ++--- .../src/content/docs/persistence-remote.mdx | 5 +- .../src/content/docs/session-state.mdx | 2 +- .../website/src/content/docs/socket-api.mdx | 83 ++++--------- .../content/docs/zh-cn/add-herdr-support.mdx | 117 ++++++++++++++++++ .../website/src/content/docs/zh-cn/agents.mdx | 10 +- .../src/content/docs/zh-cn/cli-reference.mdx | 12 +- .../src/content/docs/zh-cn/configuration.mdx | 3 + .../docs/zh-cn/connecting-machines.mdx | 12 +- .../src/content/docs/zh-cn/integrations.mdx | 35 ++---- .../content/docs/zh-cn/persistence-remote.mdx | 2 +- .../src/content/docs/zh-cn/session-state.mdx | 2 +- .../src/content/docs/zh-cn/socket-api.mdx | 33 ++--- .../website/src/data/config-reference.json | 12 +- 31 files changed, 656 insertions(+), 251 deletions(-) create mode 100644 docs/preview/website/src/content/docs/add-herdr-support.mdx create mode 100644 docs/preview/website/src/content/docs/ja/add-herdr-support.mdx create mode 100644 docs/preview/website/src/content/docs/zh-cn/add-herdr-support.mdx diff --git a/distribution/preview.json b/distribution/preview.json index d802c093..b609cf1f 100644 --- a/distribution/preview.json +++ b/distribution/preview.json @@ -2,36 +2,67 @@ "schema_version": 1, "channel": "preview", "base_version": "0.9.1", - "build_id": "2026-09-21-0ff0f27e2226", - "commit": "0ff0f27e222633c97ba4291f6b9be4137002ca84", - "built_at": "2026-09-20T21:32:22Z", + "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-21-0ff0f27e2226\n\n[View changes](https://github.com/herdrdev/herdr/compare/2c29fb29e30220bc48d783af90ba8f53b81fd96c...0ff0f27e222633c97ba4291f6b9be4137002ca84)", + "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-21-0ff0f27e2226/herdr-linux-x86_64", - "sha256": "70a91903c62630616c07786b955adb513897889ffe77946bf8e29defc7b9d309" + "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-21-0ff0f27e2226/herdr-linux-aarch64", - "sha256": "da11bf1fbcc07950962788f251220118dbb693585d7bb1eac8a4bb630a731a18" + "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-21-0ff0f27e2226/herdr-macos-x86_64", - "sha256": "29837230a907dd01d684f54646500caee8ed24b667f8965f975ebd70f9df693d" + "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-21-0ff0f27e2226/herdr-macos-aarch64", - "sha256": "203d7b29599a8bafaf94ba476912e7173cac2fb73d2988492ae9abe4e0fb44da" + "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-21-0ff0f27e2226/herdr-windows-x86_64.zip", - "sha256": "0bfd610f32becb5f299bb7bbcb48e4e6d07c1f57e41601ff3c0d7c99600b586d", + "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", @@ -874,31 +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" - } - } } } } diff --git a/docs/preview/website/src/content/docs/add-herdr-support.mdx b/docs/preview/website/src/content/docs/add-herdr-support.mdx new file mode 100644 index 00000000..0ed689a3 --- /dev/null +++ b/docs/preview/website/src/content/docs/add-herdr-support.mdx @@ -0,0 +1,117 @@ +--- +title: Add Herdr support to your agent +description: Report your coding agent's state and resume command to Herdr from your own code, without adding native support to Herdr. +--- + +If you build a coding agent, you can make it a first-class citizen in Herdr from your own code. You don't need a pull request to Herdr, and you don't need to wait for a Herdr release. + +Once your agent reports to Herdr, users get: + +- your agent's name and `idle`, `working`, or `blocked` state in the sidebar and in `herdr agent list` +- notifications when it finishes or needs a decision +- `herdr agent wait` and other automation that waits on agent state +- the same session back in the same pane after a Herdr server restart + +Herdr owns the integrations for agents whose vendors don't ship one. If you maintain your agent, owning the integration yourself means you can fix it on your own release schedule. + +## How it works + +Every process in a Herdr pane inherits these environment variables: + +| Variable | Meaning | +| --- | --- | +| `HERDR_ENV` | `1` inside Herdr | +| `HERDR_PANE_ID` | The pane your agent runs in | +| `HERDR_BIN_PATH` | The Herdr binary that owns this pane | +| `HERDR_SOCKET_PATH` | Herdr's API socket | + +Your agent makes three kinds of reports through `"$HERDR_BIN_PATH"`: + +1. **State.** Report `idle`, `working`, or `blocked` whenever it changes. +2. **Resume command.** Report the command that reopens the current session. +3. **Release.** Release the pane when your agent exits. + +Only report when `HERDR_ENV=1` and the other variables are set. Outside Herdr, your integration should do nothing. + +## Report state + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state working \ + --seq 1 +``` + +- Report `working` when a turn starts, `idle` when your agent is ready for input, and `blocked` when it needs the user to decide something. Add `--message` to explain a block. +- `--agent` is the name users see. Use your own agent's name, not the name of an agent Herdr already supports. +- `--source` identifies your integration. Keep it stable and unique. Don't start it with `herdr:`, because Herdr's own integrations use that prefix. +- `--seq` is optional but recommended. It must increase with every report from your source, including across sessions and restarts of your agent. A timestamp works well. Herdr ignores reports whose number is not higher than the last one it accepted, so late or out-of-order reports can't overwrite newer state. + +## Report the resume command + +Put the command that resumes the current session after `--`. Include the options that session needs, such as the model or permission mode, so the resumed session behaves the same: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state idle \ + --seq 2 \ + --agent-session-id "$SESSION_ID" \ + -- my-agent --resume "$SESSION_ID" --model my-model +``` + +You can attach the command to any state report, or send it with `pane report-agent-session` when only the session changes. Report it again whenever your agent switches sessions. + +After a Herdr server restart, Herdr opens the pane in the same directory and runs that command there. The command must follow these rules: + +- The first word is a plain command name on the user's `PATH`, such as `my-agent`, not a path. +- No argument contains an apostrophe. +- Your source must hold the pane first, so send a state report with `report-agent` before or along with the command. Otherwise Herdr answers with `resume_not_accepted`. + +Herdr rejects a command that breaks the first two rules with `invalid_resume_argv` and does not apply that report. Users can turn off resume with `[session] resume_agents_on_restore = false`. + +## Release on exit + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --seq 3 +``` + +Releasing clears your agent's name, state, and resume command from the pane right away. Only release when the user actually quits. If your agent replaces one session with another in the same process, report the new session instead of releasing. + +If your agent exits without releasing, Herdr notices once the pane is back at its idle shell prompt and clears the agent and its resume command. This usually takes a second or two. It is a safety net, not a replacement for releasing. + +## Keep it out of the way + +- Don't let Herdr slow your agent down. Send reports in the background or with a short timeout, and ignore failures. +- Send only the latest state. If several changes queue up while a report is in flight, drop the older ones. +- Older Herdr versions ignore the resume command, so everything else keeps working there. + +## Use the socket directly + +`HERDR_BIN_PATH` and the CLI are the portable choice, including on Windows. If your agent needs direct IPC, send the equivalent `pane.report_agent`, `pane.report_agent_session`, and `pane.release_agent` requests described in the [Socket API](/docs/socket-api/#agent-state-reporting). The resume command goes in the `resume_argv` array. + +## Check your integration + +Start your agent in a Herdr pane, then check what Herdr sees: + +```bash +herdr pane get "$HERDR_PANE_ID" +herdr agent list +``` + +To test restore without touching your normal session, use a separate named session: + +1. Start `herdr --session my-agent-test`, then run your agent in a pane. +2. Stop that session with `herdr session stop my-agent-test`. +3. Start `herdr --session my-agent-test` again. The pane should run your resume command and your agent should report again. + +## Example + +[Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world integration. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, keeps its report order across sessions, and releases the pane on exit. + +To change how your agent appears without changing its state, such as a title or custom status text, see [Custom status labels](/docs/integrations/#custom-status-labels). diff --git a/docs/preview/website/src/content/docs/agent-automation.mdx b/docs/preview/website/src/content/docs/agent-automation.mdx index e7c02ae3..def306fa 100644 --- a/docs/preview/website/src/content/docs/agent-automation.mdx +++ b/docs/preview/website/src/content/docs/agent-automation.mdx @@ -77,6 +77,8 @@ Pane input addresses the terminal regardless of its current occupant. Agent inpu `idle` and `done` both mean the agent is ready for input. The CLI/API uses the server's seen state: `done` is idle but not yet marked seen, explicit `pane focus` / `agent focus` commands mark the target seen, and reads do not. Each TUI client tracks viewed completions independently, so a client's Done badge can differ from the CLI or another client's badge. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify its lifecycle confidently; it does not prove successful completion. Use exact `--until` states when that distinction matters. +Startup readiness, restoring an idle conversation, and switching agent conversations do not count as completed work. An agent that starts working immediately can finish its first task without Herdr first seeing an idle prompt. Agent responses may include `completion_seq`, identifying the current idle transition as completed work independently of who has viewed it. It matches that transition's `state_change_seq`; startup and session changes do not set it. Older servers may omit this field. Clients connected to older servers use observed working states to guard Done badges, so they may miss a turn that starts and finishes between updates. + A timeout or `agent_prompt_stalled` does not prove that no input was sent. Read the agent before retrying to avoid submitting the same prompt twice. IDs and agent names are scoped to one server; selecting another machine in the TUI does not retarget CLI commands running in an existing pane. `pane wait-output` does not interpret agent lifecycle. It polls the selected terminal snapshot and searches it immediately, so text that was already present can match. The default source name is `recent`; matching treats that source as unwrapped recent output from the latest 80 rendered terminal rows. `--lines` changes that row limit, and `--regex` uses Rust regular-expression syntax and matches one line at a time. diff --git a/docs/preview/website/src/content/docs/agents.mdx b/docs/preview/website/src/content/docs/agents.mdx index d0612c3f..5c4351a5 100644 --- a/docs/preview/website/src/content/docs/agents.mdx +++ b/docs/preview/website/src/content/docs/agents.mdx @@ -58,9 +58,11 @@ Some restricted Linux runtimes do not expose a terminal foreground process group ## Blocked state -Blocked detection is deliberately strict for screen-manifest agents. Herdr only marks `blocked` when the live bottom-buffer snapshot matches known visible approval, question, or permission UI. If no manifest rule matches for a known agent, Herdr falls back to `idle` and labels that fallback as `default_known_agent_idle_fallback` in explain output. +Blocked detection is deliberately strict for screen-manifest agents. Herdr only marks `blocked` when the live bottom-buffer snapshot matches known visible approval, question, or permission UI. If no manifest rule matches for a known agent other than Codex, Herdr falls back to `idle` and labels that fallback as `default_known_agent_idle_fallback` in explain output. Codex falls back to `unknown` because its title and composer can look the same during an active turn and after a response. -This means unusual new agent prompts may initially show as `idle` instead of `blocked` until Herdr learns that screen shape. The misclassification affects only the visible status and waits. It should not make Herdr send input or take destructive action. +For those other agents, unusual new prompts may initially show as `idle` instead of `blocked` until Herdr learns that screen shape. The misclassification affects only the visible status and waits. It should not make Herdr send input or take destructive action. + +For Codex, a visible spinner or live activity timer can establish `working`, and a visible approval prompt can establish `blocked`. An ordinary title, composer, or missing spinner cannot establish that a turn ended. Codex may therefore stay `unknown` after a response, and waits for `idle` or completion may time out. Managed startup uses the initial composer only to determine when it can accept a prompt; that observation does not change turn status. ## Detection manifests @@ -89,6 +91,14 @@ Live explain is evaluated by the running server, so it reflects the active manif Herdr can run inside tmux as the outer terminal environment. Agent detection does not inspect tmux sessions launched inside a Herdr pane. If a shell framework auto-enters tmux inside Herdr, Herdr sees `tmux` as the pane process instead of the agent behind it. +If your shell automatically attaches to tmux whenever `TMUX` is unset, update its startup rule to exclude Herdr panes. Reattaching the outer tmux session from inside a Herdr pane can recursively shrink the terminal and make it flicker. Add the `HERDR_ENV` check to your existing condition before it runs tmux: + +```zsh +if [[ -z ${TMUX:-} && ${HERDR_ENV:-} != 1 ]]; then + tmux new-session -A -s my-session +fi +``` + ## State rollups The sidebar rolls state upward. @@ -106,7 +116,7 @@ herdr integration install claude herdr integration status ``` -Each supported agent has its own integration name and behavior. See [Integrations](/docs/integrations/) for the per-agent details and the full install list. If you are building an agent, the [custom integration guide](/docs/integrations/#integrate-your-own-agent) shows how to report lifecycle state without adding native support to Herdr. +Each supported agent has its own integration name and behavior. See [Integrations](/docs/integrations/) for the per-agent details and the full install list. If you are building an agent, [Add Herdr support to your agent](/docs/add-herdr-support/) shows how to report lifecycle state and a resume command without adding native support to Herdr. ## Custom agent labels diff --git a/docs/preview/website/src/content/docs/cli-reference.mdx b/docs/preview/website/src/content/docs/cli-reference.mdx index cf9b355f..31fe26a9 100644 --- a/docs/preview/website/src/content/docs/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/cli-reference.mdx @@ -51,6 +51,7 @@ with the installed binary. Use `--json` for the full JSON Schema document, or ```bash herdr machine list +herdr machine add workbox herdr machine add workbox --label "Build machine" herdr machine rename <profile-id> --label "New name" herdr machine disable <profile-id> @@ -58,13 +59,13 @@ herdr machine enable <profile-id> herdr machine remove <profile-id> ``` -By default, `machine add` uses the remote default session. Add `--remote-session <name>` only for a named session. `machine list` accepts optional `--json` for scripts. See [Connecting machines](/docs/connecting-machines/) for the full setup and connection guide. +In an interactive terminal, `machine add` discovers running sessions and lets you choose when several are running. No running sessions, or no Herdr installation, uses `default`; failed queries against an installed binary require an explicit choice. Add `--remote-session <name>` to skip discovery. Non-interactive commands use `default` unless that flag is supplied. `machine list` accepts optional `--json` for scripts. See [Connecting machines](/docs/connecting-machines/) for the full setup and connection guide. `machine add` checks the remote installation's capabilities, installs or updates with approval only when needed, and starts the requested session's background server before saving. Compatible release versions do not need to match. Run setup in an interactive terminal when installation or restart approval is required; failed or cancelled setup does not save a profile. Replacing a running server requires explicit approval with a default answer of No, and stops its pane processes. `machine add` never enables experimental handoff implicitly. Changes apply automatically to open local Herdr clients, normally within a second. Added or enabled machines connect in the background; renaming does not reconnect. Removing or disabling disconnects only that machine and leaves its remote sessions running. Removing the machine you are viewing returns to Local, or shows Local as unavailable until it reconnects. Each profile stores an opaque ID, label, SSH target, explicit remote session, and enabled state in client state. Herdr does not store passwords, private keys, or other SSH credentials. -Automatic connections and reconnects are non-interactive. If a host key, password, key passphrase, MFA step, install, update, or restart needs approval, the machine shows Attention instead of opening a hidden prompt. Run the standalone command printed by Herdr, such as `herdr --remote workbox`, to complete that setup in the foreground, then restart the client. Include `--session <name>` only when the profile targets a named session. +Use `herdr machine status [<label-or-id>] [--json]` for fresh, noninteractive SSH and remote Herdr checks. `reachable` describes this check, not a TUI's current connection. Use `herdr machine reconnect <label-or-id>` in a terminal for SSH authentication and verification; it does not install or update Herdr. Open clients retry failed connections within 30 seconds. Clicking `! auth` or `! error` shows the latest error and CLI guidance; collapsed sidebars show `!`. See [Recovering SSH authentication](/docs/connecting-machines/#recovering-ssh-authentication) for platform limits. For remote setup, use `herdr --remote workbox`, adding `--session <name>` only for a named session. Workspace, tab, pane IDs, and agent names belong to a single server. Selecting a machine in the UI does not retarget CLI commands. Use the global **prefix** `--machine <label-or-id>` to route API commands to a saved SSH machine: @@ -277,7 +278,8 @@ herdr pane report-agent <pane_id> \ [--message TEXT] \ [--seq N] \ [--agent-session-id ID] \ - [--agent-session-path PATH] + [--agent-session-path PATH] \ + [-- RESUME_COMMAND...] herdr pane report-agent-session <pane_id> \ --source ID \ @@ -285,7 +287,8 @@ herdr pane report-agent-session <pane_id> \ [--seq N] \ [--agent-session-id ID] \ [--agent-session-path PATH] \ - [--session-start-source SOURCE] + [--session-start-source SOURCE] \ + [-- RESUME_COMMAND...] herdr pane release-agent <pane_id> \ --source ID \ @@ -295,6 +298,8 @@ herdr pane release-agent <pane_id> \ `report-agent-session` updates native session identity without reporting lifecycle state. `release-agent` ends that source's lifecycle authority when its agent process exits. +Arguments after `--` are the command Herdr runs to resume this session after a server restart. The first argument must be a plain command name on `PATH`, and no argument may contain an apostrophe. + `pane get`, `pane list`, `agent get`, and `agent list` include a read-only `agent_session` object when an official integration has reported a native session reference. If no native session reference is stored, the field is omitted. Those commands include `foreground_cwd` when Herdr can resolve the cwd of the foreground process controlling the pane. The `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior. @@ -376,8 +381,17 @@ Detach from direct attach with `ctrl+b q`. Send literal `ctrl+b` with `ctrl+b ct terminal, or agent target. It prints the same newline-delimited `terminal.frame` and `terminal.closed` records as observe mode. It reads newline-delimited JSON commands on stdin: `terminal.input`, `terminal.resize`, -`terminal.scroll`, and `terminal.release`. One controller can own a terminal at -a time; use `--takeover` to replace it. +`terminal.scroll`, `terminal.mouse`, and `terminal.release`. One controller can +own a terminal at a time; use `--takeover` to replace it. +`terminal.mouse` sends one mouse event at a zero-based cell of the controller's +viewport, for example +`{"type":"terminal.mouse","action":"down","button":"left","column":12,"row":5}`. +`action` is `down`, `up`, `drag`, or `move`; `button` is `left` (default), +`right`, or `middle`; `modifiers` is optional and combines Shift = 1, Ctrl = 2, +and Alt = 4 (default 0). A click is `down` followed by `up`. Herdr encodes the +event for the application's mouse mode and drops it when the application has +not enabled mouse reporting, so a plain shell never receives stray input. +`move` reaches only applications that track all mouse motion. `terminal session observe` opens a read-only live terminal stream for a pane, terminal, or agent target. It prints newline-delimited JSON `terminal.frame` records with base64-encoded ANSI bytes, then a `terminal.closed` record when @@ -517,6 +531,17 @@ terminal size, and values smaller than the popup minimum are clamped. A popup is not a Herdr pane, does not export `HERDR_PANE_ID`, and does not participate in pane or agent APIs. Native non-terminal plugin panes are outside plugin v1. +New panes identify as `TERM_PROGRAM=herdr`, with `TERM_PROGRAM_VERSION` set to +Herdr's version. They keep `TERM=xterm-256color` and `COLORTERM=truecolor`. +Herdr removes inherited outer-terminal session markers (iTerm2, WezTerm, Kitty, +Windows Terminal, tmux, screen, and Zellij), iTerm2's `LC_TERMINAL` identity, +and known Claude Code, Codex, and OMP session markers before applying explicit +launch environment values. This prevents new panes from claiming the terminal +or agent session that originally started the server. Ordinary environment +variables, including API keys and display settings, remain inherited rather than +being replaced with the attaching client's environment. SSH-agent forwarding has +its own reconnect handling. Existing pane processes are unchanged by this cleanup. + `--env KEY=VALUE` can be repeated on process-launching commands. It applies to the newly launched process only. Herdr-managed variables such as `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ENV`, `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, `HERDR_PANE_ID`, `HERDR_PLUGIN_ID`, `HERDR_PLUGIN_ROOT`, `HERDR_PLUGIN_CONFIG_DIR`, `HERDR_PLUGIN_STATE_DIR`, `HERDR_PLUGIN_ENTRYPOINT_ID`, and `HERDR_PLUGIN_CONTEXT_JSON` stay authoritative when they conflict with caller-provided env. ## Read sources diff --git a/docs/preview/website/src/content/docs/configuration.mdx b/docs/preview/website/src/content/docs/configuration.mdx index c54c79a9..33644e1c 100644 --- a/docs/preview/website/src/content/docs/configuration.mdx +++ b/docs/preview/website/src/content/docs/configuration.mdx @@ -142,6 +142,15 @@ navigate_pane_down = "ctrl+j" split_horizontal = "prefix+minus" ``` +`keys.prefix` also accepts an array when you need to enter prefix mode from more than one key, for example to share one config between macOS and a remote Linux machine: + +```toml +[keys] +prefix = ["ctrl+space", "ctrl+s"] +``` + +Every configured prefix enters the same prefix mode and starts every `prefix+...` action. The first entry is the primary prefix shown in the status bar; the in-app help panel at `prefix+?` lists all of them. + The default keymap is prefix-first so Herdr does not steal input from shells, editors, tmux, or terminal apps. Search `keys.` in the [Config reference](/docs/config-reference/) to see every action and default binding. The in-app help panel at `prefix+?` shows the active bindings. A binding may also be an array when one action needs multiple shortcuts: @@ -248,6 +257,8 @@ name = "catppuccin" Search `theme.name` in the [Config reference](/docs/config-reference/) for every built-in theme. Use `terminal` when you want Herdr UI colors to follow your host terminal's ANSI palette. +On Unix, Herdr also re-reads the host terminal's colors when redrawing after a resize or `SIGWINCH`. If your theme switcher changes terminal colors through OSC without a light/dark notification, send `kill -WINCH <client-pid>` after applying the new palette. Target the attached Herdr client, not the server; existing panes stay running. Direct `herdr terminal attach` sessions leave palette queries to the attached application. + To let Herdr switch its own UI theme when the host terminal reports a light/dark appearance change, enable theme auto-switching: ```toml @@ -512,9 +523,19 @@ Search the [Config reference](/docs/config-reference/) for scrollback limits, ne ## Kitty graphics -Herdr renders pane images by default in compatible outer terminals. Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. +Applications and plugins integrate through the standard Kitty graphics protocol emitted in pane terminal output. Herdr renders these images by default in compatible outer terminals. The external socket pane-overlay API has been removed; there is no replacement socket image API. -To disable graphics rendering and the pane graphics API: +Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. + +Herdr automatically chooses eligible file transports, with no application changes or environment switches. On local Unix Ghostty clients, it can send image data through temporary files instead of encoding it into terminal output. A small file-consumption probe checks support first. Other terminals, SSH-launched clients, and nested multiplexers retain inline output for this last-hop optimization. + +For eligible local clients, native RGBA images travel from server to client as paths to Herdr-owned snapshots rather than pixel bytes. Unsupported clients and failed transfers fall back to inline delivery. Remote server paths are never forwarded to the local terminal. Herdr keeps image placement, clipping, and visibility under its control. + +On Linux, whole-file, uncompressed RGBA uploads can use copy-on-write snapshots before Herdr reads their pixels. This requires filesystem support for cloning into Herdr's private `/var/tmp` store. Unsupported filesystems, temporary-file uploads, shared memory, and other formats use the normal loader. Snapshots are limited to 16 MiB each and 64 MiB total; animation and unsupported clients read their pixels when needed. Herdr never relies on a mutable producer path after accepting an image. + +PNG uploads retain full decoding and validation. Herdr does not defer corrupt-image validation to the outer terminal. + +To disable Kitty graphics rendering: ```toml [terminal] @@ -523,7 +544,7 @@ kitty_graphics = false The legacy `experimental.kitty_graphics` setting remains accepted for existing configurations. `terminal.kitty_graphics` takes precedence when both are set. -Changing this setting requires restarting the affected Herdr server or reattaching the client. In remote sessions, the server's setting controls pane graphics parsing and API availability, while the local client's setting controls output to the outer terminal. +Changing this setting requires restarting the affected Herdr server or reattaching the client. In remote sessions, the server's setting controls Kitty graphics parsing, while the local client's setting controls output to the outer terminal. ## Agent session restore @@ -532,8 +553,11 @@ Herdr resumes supported Agent conversations after a server restart by default: ```toml [session] resume_agents_on_restore = true +startup_per_agent_delay_ms = 100 ``` +Automatic restores start one agent at a time, 100 milliseconds apart by default, to reduce startup contention. The first eligible agent starts immediately. Set `startup_per_agent_delay_ms = 0` to disable spacing. Manual launches and explicit terminal attaches are unaffected. Restart the Herdr server to apply changes. + Only panes with a valid native session reference from an official integration can resume; other panes restore as normal shells. See [Session state and restore](/docs/session-state/) for supported Agents and persistence behavior. ## IME cursor tracking diff --git a/docs/preview/website/src/content/docs/connecting-machines.mdx b/docs/preview/website/src/content/docs/connecting-machines.mdx index 3f7bcfe6..47bc19a5 100644 --- a/docs/preview/website/src/content/docs/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/connecting-machines.mdx @@ -9,6 +9,12 @@ Each machine keeps its own Herdr server, sessions, and running processes. A lost Herdr requests SSH compression to reduce screen-update traffic on limited connections. This works with older compatible Herdr servers and does not change how typing or other input is delivered. Herdr's `-C` option overrides `Compression no` in your SSH config. If SSH reuses an existing connection through an external ControlMaster, that connection keeps its original compression setting. +### Recovering SSH authentication + +On Linux and macOS clients, when `remote.manage_ssh_config=true`, Herdr shares an approved OpenSSH connection across machine add, the sidebar, and saved-machine API commands. Run `herdr machine status [<label-or-id>] [--json]` for fresh, noninteractive checks. **Reachable** means the remote Herdr server is available now, not that every open TUI is connected. Run `herdr machine reconnect <label-or-id>` in a terminal to complete native SSH authentication and verify the saved machine. It does not install or update Herdr. Open clients recheck failed connections within 30 seconds; no restart is needed. Clicking **`! auth`**, **`! error`**, or a failed connection's reconnecting status shows a notice with its latest error and the relevant CLI command. It never opens a credential popup. The rest of the machine row keeps its normal expand/collapse behavior. + +The shared connection uses `ControlPersist 600` (10 minutes of idle time); this is not MFA validity. Active connections can remain open, while corporate policy, the SSH server, or the network may end them sooner. Local Windows recovery is not supported yet. For unmanaged SSH connections, authentication remains your responsibility: running `ssh` by itself does not grant Herdr approval unless it uses the shared ControlPath. Host-key checking is unchanged; an unknown or changed host key remains **Attention**. + ## Before you connect You need normal SSH access to the remote machine. Verify it first: @@ -26,17 +32,23 @@ Multi-machine connections support Linux, macOS, and Windows clients connecting t Run setup in an interactive terminal so Herdr can ask before installing or replacing anything: ```bash -herdr machine add workbox --label "Build machine" +herdr machine add workbox ``` -This uses the remote default session. A machine profile targets one remote session; it does not combine every session on the host. +In an interactive terminal, Herdr discovers running sessions: it selects the only running session, or lets you choose with Up/Down and Enter when several are running. Esc or Ctrl+C cancels. If a successful query finds no running sessions, setup uses `default`. If no Herdr installation is found, setup offers to install it for `default`. If an installed binary cannot report its sessions, setup reports the error; pass `--remote-session <name>` to select a session explicitly. + +The sidebar shows the default session as `workbox`, the SSH host without any `user@` prefix. Add `--label "Build machine"` to choose a different name. A machine profile targets one remote session; it does not combine every session on the host. Non-interactive commands use `default` unless `--remote-session` is supplied. -To use a named session instead, add the optional `--remote-session` flag: +To skip discovery and use a specific session, add `--remote-session`: ```bash -herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add workbox --remote-session agents ``` +Without `--label`, this machine shows as `workbox/agents`. If the default name is already taken, `machine add` asks you to pass `--label`. + +On Windows servers, setup also checks executables used by Herdr processes owned by the SSH user. It validates their capabilities before reuse, so a compatible running build can be used even when SSH's `PATH` points to an older installation. + Herdr checks both the installed binary and the running server. It starts the requested background server before saving the profile. Compatible client and server versions do not have to match. Missing or incompatible installations go through an approval-based setup. When the running server needs replacement, setup asks before stopping it and its pane processes, then starts the compatible server. The default answer is No. If installation and replacement are both needed, one confirmation covers them. `machine add` does not use experimental live handoff. Cancelling or failing setup leaves the profile unsaved. Run `herdr` to open the UI. If a local client is already open, added and enabled machines normally appear within a second and connect in the background without changing your selection. An in-progress machine switch finishes before profile changes are applied. The remote server keeps running after setup exits. @@ -76,21 +88,25 @@ Removing or disabling the machine you are viewing returns you to Local. If Local ## Connection problems - **Reconnecting:** Herdr retries automatically after a network interruption, sleep, or SSH failure. Repeated failures increase the delay up to two minutes; brief successful connections do not reset it. A connection must remain healthy for a minute before the next interruption gets a fast retry. SSH connections are checked for application-level activity and probed when quiet, so a broken connection does not stay Online indefinitely. Local detects native connection closure or failure instead of using remote health probes. -- **Attention:** The target needs an action that cannot be completed in the background, such as host-key approval, authentication, or a compatible server. Other machines remain usable. +- **Attention:** The target needs an action such as authentication, host-key approval, or a compatible server. Other machines remain usable. **`! auth`** means authentication failed; **`! error`** indicates another issue. Click the badge for the error and a CLI command. In a collapsed sidebar, either appears as `!`. - **Saved-machine file error:** An unreadable or invalid catalog leaves current connections unchanged. Herdr shows a notice and automatically retries reading it. When both installations support bridge idle cleanup, saved-machine connections to Linux and macOS also close their remote bridge after a minute without traffic in either direction. Sleep counts toward that deadline, which is checked when the host wakes. Quiet healthy connections exchange health checks; watching continuous output does not require typing. Cleanup leaves the remote Herdr server, sessions, and pane processes running. Older installations remain compatible without this optional cleanup. -Background connections never answer prompts or install, update, restart, or hand off a server. For Attention, run the standalone setup command shown by Herdr in an interactive terminal, for example: +Background connections never answer prompts or install, update, restart, or hand off a server. For non-authentication Attention, run the standalone setup command shown by Herdr in an interactive terminal, for example: ```bash herdr --remote workbox ``` -Use your profile's target. If you chose a named session when adding it, include the optional `--session <name>` here too. Follow any approval prompts, then restart the client to retry the Attention connection. Do not stop a running server merely because its version differs from the client. +Use your profile's target. If you chose a named session when adding it, include the optional `--session <name>` here too. Follow the requested setup prompts; open clients retry automatically. `machine reconnect` does not install or update Herdr. Do not stop a running server merely because its version differs from the client. If authentication fails, check ordinary SSH first. For a passphrase-protected key, load it with `ssh-add` before starting Herdr's non-interactive background connections. +For Git authentication or SSH signing inside remote panes, enable `ForwardAgent yes` for the trusted host in your SSH config. Herdr does not enable forwarding for you. When a session starts with an agent, updated Linux and macOS servers give panes a stable agent address, so existing and new panes can use the forwarded agent after `--remote` or a saved machine reconnects. A working inherited agent or earlier connection keeps priority over later clients and temporary setup checks; if it disappears, another live attachment can supply the agent. + +This requires an updated remote server, not just an updated local client. Local sessions started without an agent leave `SSH_AUTH_SOCK` alone. Panes created before the server update, or before an agent was first supplied to the session, keep their original environment and need to be recreated once to inherit the stable address. Older compatible servers and connections without agent forwarding can still attach normally. + ## Settings and automation The UI uses the client's local theme, sidebar settings, and keybindings by default. Custom commands and plugins advertised by the selected server still run there. Herdr does not copy local command plugins, configuration, executables, or secrets onto SSH hosts. Missing remote commands fail visibly. Use the UI's `reload config` action after editing client settings; see [Configuration](/docs/configuration/#reload-config). diff --git a/docs/preview/website/src/content/docs/install.mdx b/docs/preview/website/src/content/docs/install.mdx index c0c422a5..4dac5ede 100644 --- a/docs/preview/website/src/content/docs/install.mdx +++ b/docs/preview/website/src/content/docs/install.mdx @@ -147,6 +147,8 @@ To opt into experimental live server handoff for supported running sessions, run herdr update --handoff ``` +When both servers support agent-status transfer, live handoff preserves hook-reported agent status until the next report arrives. Handoff from an older server can temporarily show a running agent as idle. + Live handoff does not apply to Homebrew, mise, or Nix package-manager updates. For those installs, update with the package manager, then run Herdr again to reconnect with the updated client. The compatible old server keeps running. Restart it later with `herdr server stop` or `herdr session stop <name>` only when you want server-side changes from the new release. ## Requirements diff --git a/docs/preview/website/src/content/docs/integrations.mdx b/docs/preview/website/src/content/docs/integrations.mdx index 7bca01e6..3aff5ae1 100644 --- a/docs/preview/website/src/content/docs/integrations.mdx +++ b/docs/preview/website/src/content/docs/integrations.mdx @@ -66,37 +66,14 @@ Herdr uses integrations in two ways: Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable. -## Integrate your own agent - -An agent running in a Herdr pane inherits `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_BIN_PATH`, and `HERDR_SOCKET_PATH`. If the agent exposes lifecycle hooks, use those hooks to report semantic state through Herdr's CLI: - -```bash -"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent \ - --state working -``` - -Report `idle` when the agent is ready for input and `blocked` when it needs a user decision. Use `--message` to describe a block. When the agent exits, release the same source's lifecycle authority: - -```bash -"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent -``` - -Report only when `HERDR_ENV=1` and the required variables are present. This keeps the integration a no-op outside Herdr. Keep `--source` stable and unique to the integration. If reports can arrive out of order, include a strictly increasing `--seq`; Herdr ignores stale sequence numbers from the same source. - -You can include `--agent-session-id` or `--agent-session-path` with `report-agent`, or use `pane report-agent-session` when session identity changes independently of state. Herdr exposes that reference through its pane and agent APIs. Automatic session restore also requires Herdr to know how to launch that agent and resume the referenced session. - -Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that needs direct IPC can send the equivalent `pane.report_agent`, `pane.report_agent_session`, and `pane.release_agent` requests described in the [Socket API](/docs/socket-api/#agent-state-reporting). - -[Prime Agent's built-in Herdr reporter](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts) is a real-world example. It activates only inside Herdr, maps agent events to `working`, `idle`, and `blocked`, preserves report ordering across sessions, and releases authority on exit. - Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. +## Integrate your own agent + +If you build a coding agent, you can report its state and resume command to Herdr from your own code, without adding native support to Herdr. See [Add Herdr support to your agent](/docs/add-herdr-support/). + ## Pi Install the Pi integration: @@ -227,6 +204,10 @@ Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting. +V1 TUI reporting follows the pane's selected session and its descendants. The pane stays working while any of them is busy or retrying, and blocked while any has a pending permission or question. Attaching loads existing activity; cancelled tool requests do not become blockers again after reconnecting. Browsing a subagent keeps the original session family, while directly attaching to a child tracks that child's subtree rather than its siblings. + +Local V1 `opencode run` and `opencode --mini` retain their legacy server hooks; this change does not add pane-local lifecycle reporting to Mini/headless clients. After updating the integration, restart shared OpenCode servers as well as their TUIs so the old server reporter cannot overwrite pane-local state. + The plugin reports lifecycle state and session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. Native screen manifest detection remains available when the plugin is not installed. ## Kilo Code CLI diff --git a/docs/preview/website/src/content/docs/ja/add-herdr-support.mdx b/docs/preview/website/src/content/docs/ja/add-herdr-support.mdx new file mode 100644 index 00000000..d095bc2d --- /dev/null +++ b/docs/preview/website/src/content/docs/ja/add-herdr-support.mdx @@ -0,0 +1,117 @@ +--- +title: エージェントに Herdr 対応を追加する +description: Herdr にネイティブサポートを追加せずに、自分のコードからコーディングエージェントの状態と resume コマンドを Herdr に報告します。 +--- + +コーディングエージェントを開発しているなら、自分のコードだけでそのエージェントを Herdr の第一級の存在にできます。Herdr へのプルリクエストも、Herdr のリリースを待つ必要もありません。 + +エージェントが Herdr に報告すると、ユーザーは次のものを得られます: + +- サイドバーと `herdr agent list` に表示されるエージェント名と `idle`、`working`、`blocked` の状態 +- 完了したときや判断が必要になったときの通知 +- `herdr agent wait` など、エージェントの状態を待つ自動化 +- Herdr サーバーの再起動後、同じペインで同じセッションが戻ること + +ベンダーがインテグレーションを提供していないエージェントについては、Herdr がインテグレーションを管理します。エージェントのメンテナーが自分でインテグレーションを持てば、自分のリリーススケジュールで修正できます。 + +## 仕組み + +Herdr ペイン内のすべてのプロセスは、次の環境変数を継承します: + +| 変数 | 意味 | +| --- | --- | +| `HERDR_ENV` | Herdr 内では `1` | +| `HERDR_PANE_ID` | エージェントが動いているペイン | +| `HERDR_BIN_PATH` | このペインを管理する Herdr バイナリ | +| `HERDR_SOCKET_PATH` | Herdr の API ソケット | + +エージェントは `"$HERDR_BIN_PATH"` を通じて 3 種類の報告をします: + +1. **状態。** `idle`、`working`、`blocked` が変わるたびに報告します。 +2. **resume コマンド。** 現在のセッションを開き直すコマンドを報告します。 +3. **解放。** エージェントの終了時にペインを解放します。 + +`HERDR_ENV=1` で他の変数も設定されているときだけ報告してください。Herdr の外では、インテグレーションは何もしないようにします。 + +## 状態を報告する + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state working \ + --seq 1 +``` + +- ターンの開始時に `working`、入力を受け付けられるときに `idle`、ユーザーの判断が必要なときに `blocked` を報告します。ブロックの理由は `--message` で説明できます。 +- `--agent` はユーザーに表示される名前です。Herdr がすでに対応しているエージェントの名前ではなく、自分のエージェントの名前を使ってください。 +- `--source` はインテグレーションを識別します。固定かつ一意にしてください。`herdr:` で始めないでください。この接頭辞は Herdr 自身のインテグレーションが使います。 +- `--seq` は省略できますが、付けることを推奨します。source からの報告ごとに増加する必要があり、エージェントのセッションや再起動をまたいでも増加し続ける必要があります。タイムスタンプが適しています。Herdr は最後に受け付けた番号より大きくない報告を無視するので、遅れて届いた報告や順不同の報告が新しい状態を上書きすることはありません。 + +## resume コマンドを報告する + +現在のセッションを再開するコマンドを `--` の後に置きます。モデルや権限モードなど、そのセッションに必要なオプションも含めると、再開したセッションが同じように動きます: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state idle \ + --seq 2 \ + --agent-session-id "$SESSION_ID" \ + -- my-agent --resume "$SESSION_ID" --model my-model +``` + +コマンドはどの状態報告に付けてもかまいません。セッションだけが変わったときは `pane report-agent-session` で送れます。エージェントがセッションを切り替えるたびに、もう一度報告してください。 + +Herdr サーバーの再起動後、Herdr は同じディレクトリでペインを開き、そこでそのコマンドを実行します。コマンドは次のルールに従う必要があります: + +- 最初の語は、`my-agent` のようにユーザーの `PATH` 上にある単純なコマンド名にします。パスは使えません。 +- どの引数にもアポストロフィを含めません。 +- 先に source がペインを保持している必要があります。コマンドより前か同時に、`report-agent` で状態を報告してください。そうしないと Herdr は `resume_not_accepted` を返します。 + +最初の 2 つのルールに違反するコマンドは、Herdr が `invalid_resume_argv` で拒否し、その報告は適用されません。ユーザーは `[session] resume_agents_on_restore = false` で resume を無効にできます。 + +## 終了時に解放する + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --seq 3 +``` + +解放すると、エージェントの名前、状態、resume コマンドがすぐにペインから消えます。解放するのはユーザーが実際に終了したときだけにしてください。同じプロセス内でセッションを別のセッションに置き換える場合は、解放せずに新しいセッションを報告します。 + +エージェントが解放せずに終了した場合、Herdr はペインがアイドルなシェルプロンプトに戻ったことに気づき、エージェントと resume コマンドを消去します。通常は 1〜2 秒かかります。これは安全網であり、解放の代わりにはなりません。 + +## エージェントの邪魔をしない + +- Herdr のせいでエージェントを遅くしないでください。報告はバックグラウンドか短いタイムアウトで送り、失敗は無視します。 +- 最新の状態だけを送ります。報告の送信中に複数の変化がたまったら、古いものは捨てます。 +- 古い Herdr は resume コマンドを無視するので、それ以外の機能はそのまま動きます。 + +## ソケットを直接使う + +`HERDR_BIN_PATH` と CLI は Windows を含めて移植性のある選択肢です。エージェントに直接の IPC が必要な場合は、[Socket API](/ja/docs/socket-api/) に記載された同等の `pane.report_agent`、`pane.report_agent_session`、`pane.release_agent` リクエストを送信します。resume コマンドは `resume_argv` 配列に入れます。 + +## インテグレーションを確認する + +Herdr のペインでエージェントを起動し、Herdr から見える内容を確認します: + +```bash +herdr pane get "$HERDR_PANE_ID" +herdr agent list +``` + +普段のセッションに影響を与えずに復元をテストするには、別の名前付きセッションを使います: + +1. `herdr --session my-agent-test` を起動し、ペインでエージェントを実行します。 +2. `herdr session stop my-agent-test` でそのセッションを停止します。 +3. `herdr --session my-agent-test` をもう一度起動します。ペインで resume コマンドが実行され、エージェントが再び報告するはずです。 + +## 例 + +[Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際のインテグレーションです。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時にペインを解放します。 + +タイトルやカスタムのステータス文言など、状態を変えずにエージェントの見た目を変えるには、[カスタムステータスラベル](/ja/docs/integrations/#カスタムステータスラベル)を参照してください。 diff --git a/docs/preview/website/src/content/docs/ja/agents.mdx b/docs/preview/website/src/content/docs/ja/agents.mdx index 3a365221..cb71a678 100644 --- a/docs/preview/website/src/content/docs/ja/agents.mdx +++ b/docs/preview/website/src/content/docs/ja/agents.mdx @@ -89,6 +89,14 @@ herdr agent explain --file screen.txt --agent codex --json Herdr は外側のターミナル環境として tmux の中で動かせます。エージェント検出は、Herdr のペイン内で起動された tmux セッションの中までは調べません。シェルフレームワークが Herdr 内で自動的に tmux に入る場合、Herdr はペインのプロセスとして背後のエージェントではなく `tmux` を見ることになります。 +`TMUX` が未設定のときにシェルが自動的に tmux に接続する場合、シェルの起動条件を更新して Herdr のペインを除外してください。Herdr のペイン内から外側の tmux セッションに再接続すると、ターミナルのサイズが再帰的に縮小し、画面がちらつくことがあります。既存の条件に、tmux を実行する前に `HERDR_ENV` の確認を追加します: + +```zsh +if [[ -z ${TMUX:-} && ${HERDR_ENV:-} != 1 ]]; then + tmux new-session -A -s my-session +fi +``` + ## 状態のロールアップ サイドバーは状態を上位へ集約します。 @@ -106,7 +114,7 @@ herdr integration install claude herdr integration status ``` -対応エージェントごとに、インテグレーションの名前と挙動は異なります。エージェント別の詳細と完全なインストール一覧は[インテグレーション](/ja/docs/integrations/)を参照してください。エージェントを開発している場合は、[カスタムインテグレーションガイド](/ja/docs/integrations/#独自エージェントを統合する)で、Herdr にネイティブサポートを追加せずにライフサイクル状態を報告する方法を確認できます。 +対応エージェントごとに、インテグレーションの名前と挙動は異なります。エージェント別の詳細と完全なインストール一覧は[インテグレーション](/ja/docs/integrations/)を参照してください。エージェントを開発している場合は、[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)で、Herdr にネイティブサポートを追加せずにライフサイクル状態と resume コマンドを報告する方法を確認できます。 ## カスタムエージェントラベル diff --git a/docs/preview/website/src/content/docs/ja/cli-reference.mdx b/docs/preview/website/src/content/docs/ja/cli-reference.mdx index 99a2e133..2904b069 100644 --- a/docs/preview/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/ja/cli-reference.mdx @@ -62,7 +62,7 @@ herdr machine remove <profile-id> 変更は、開いているローカルの Herdr クライアントに通常 1 秒以内に自動反映されます。追加または有効化したマシンにはバックグラウンドで接続し、名前の変更では再接続しません。削除または無効化すると、そのマシンだけが切断され、リモートセッションは動き続けます。表示中のマシンを削除すると Local に戻ります。Local に接続できない場合は、再接続するまで利用不可と表示します。各プロファイルは、不透明な ID、ラベル、SSH 接続先、明示的なリモートセッション名、有効状態をクライアントの状態ファイルに保存します。パスワード、秘密鍵、その他の SSH 認証情報は保存しません。 -自動接続と再接続は非対話型です。ホスト鍵、パスワード、鍵のパスフレーズ、MFA、インストール、更新、再起動に承認が必要な場合、隠れたプロンプトを開くのではなく Attention と表示します。`herdr --remote workbox` など、Herdr が表示する単独接続用のコマンドを実行してフォアグラウンドでセットアップを完了し、クライアントを再起動してください。名前付きセッションを選んだプロファイルにだけ `--session <name>` を追加します。 +`herdr machine status [<label-or-id>] [--json]` は SSH とリモート Herdr を非対話式で確認します。`reachable` は今回の確認結果で、TUI の接続状態ではありません。ターミナルで `herdr machine reconnect <label-or-id>` を実行すると認証と検証を行いますが、インストールや更新は行いません。開いているクライアントは 30 秒以内に再試行します。`! auth` または `! error` をクリックすると最新のエラーと CLI コマンドを表示します。折りたたみ時は `!` です。対応環境は [SSH 認証の復旧](/ja/docs/connecting-machines/#ssh-認証の復旧)を参照してください。セットアップには `herdr --remote workbox` を使い、名前付きセッションの場合だけ `--session <name>` を追加します。 ワークスペース、タブ、ペインの ID とエージェント名はサーバーごとに独立しています。UI での選択は CLI の接続先を変えません。保存済み SSH マシンへ API コマンドを送るには、グローバルな**プレフィックス** `--machine <label-or-id>` を指定します。 @@ -258,7 +258,8 @@ herdr pane report-agent <pane_id> \ [--message TEXT] \ [--seq N] \ [--agent-session-id ID] \ - [--agent-session-path PATH] + [--agent-session-path PATH] \ + [-- RESUME_COMMAND...] herdr pane report-agent-session <pane_id> \ --source ID \ @@ -266,7 +267,8 @@ herdr pane report-agent-session <pane_id> \ [--seq N] \ [--agent-session-id ID] \ [--agent-session-path PATH] \ - [--session-start-source SOURCE] + [--session-start-source SOURCE] \ + [-- RESUME_COMMAND...] herdr pane release-agent <pane_id> \ --source ID \ @@ -276,6 +278,8 @@ herdr pane release-agent <pane_id> \ `report-agent-session` はライフサイクル状態を報告せずにネイティブセッション識別子を更新します。`release-agent` は、そのエージェントプロセスが終了したときに、そのソースのライフサイクル権限を終了します。 +`--` の後の引数は、サーバー再起動後にこのセッションを再開するために Herdr が実行するコマンドです。最初の引数は `PATH` 上の単純なコマンド名でなければならず、どの引数にもアポストロフィを含めることはできません。 + 公式インテグレーションがネイティブセッション参照を報告している場合、`pane get`、`pane list`、`agent get`、`agent list` は読み取り専用の `agent_session` オブジェクトを含みます。ネイティブセッション参照が保存されていない場合、このフィールドは省略されます。 これらのコマンドは、ペインを制御しているフォアグラウンドプロセスの cwd を解決できる場合に `foreground_cwd` を含みます。既存の `cwd` フィールドは、ラベルと follow-cwd 挙動に使われるペイン/ワークスペースの cwd のままです。 @@ -351,7 +355,7 @@ herdr terminal title clear ``` ダイレクトアタッチからは `ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 -`terminal session control` は、ペイン、ターミナル、エージェントを対象に書き込み可能なライブストリームを開きます。observe と同じ改行区切り JSON の `terminal.frame` と `terminal.closed` を出力し、標準入力から `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` を受け取ります。制御クライアントは同時に 1 つだけで、`--takeover` で置き換えられます。 +`terminal session control` は、ペイン、ターミナル、エージェントを対象に書き込み可能なライブストリームを開きます。observe と同じ改行区切り JSON の `terminal.frame` と `terminal.closed` を出力し、標準入力から `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.mouse`、`terminal.release` を受け取ります。制御クライアントは同時に 1 つだけで、`--takeover` で置き換えられます。`terminal.mouse` は 0 始まりのセル位置にマウスイベントを 1 つ送ります(例: `{"type":"terminal.mouse","action":"down","button":"left","column":12,"row":5}`)。`action` は `down`、`up`、`drag`、`move` のいずれかで、`button` は `left`(既定)、`right`、`middle`、`modifiers` は省略可能で Shift = 1、Ctrl = 2、Alt = 4 の組み合わせです(既定 0)。クリックは `down` の後に `up` を送ります。Herdr はアプリのマウスモードに合わせてエンコードし、マウス報告を有効にしていないアプリには送らないため、通常のシェルに余計な入力が届くことはありません。`move` はすべてのマウス移動を追跡するアプリにのみ届きます。 `terminal session observe` は同じ対象の読み取り専用ストリームを開きます。base64 の ANSI バイト列を含む `terminal.frame` と、サーバー終了時の `terminal.closed` を改行区切り JSON で出力します。複数の観測クライアントは入力、リサイズ、スクロール、制御権を取得せずに同じターミナルを監視できます。Linux/macOS ではソケットへの書き込みが 30 秒間進まない観測クライアントを切断します。その場合は最後の `terminal.closed` が届かないことがありますが、ペインや他のクライアントは動き続けます。出力のない静かなペインはこのタイムアウトの対象ではありません。 diff --git a/docs/preview/website/src/content/docs/ja/configuration.mdx b/docs/preview/website/src/content/docs/ja/configuration.mdx index 12802e84..65b683f8 100644 --- a/docs/preview/website/src/content/docs/ja/configuration.mdx +++ b/docs/preview/website/src/content/docs/ja/configuration.mdx @@ -528,8 +528,11 @@ Herdr はデフォルトで、サーバーの再起動後に対応する Agent ```toml [session] resume_agents_on_restore = true +startup_per_agent_delay_ms = 100 ``` +起動時の競合を減らすため、自動復元ではデフォルトで 100 ミリ秒間隔でエージェントを 1 つずつ起動します。起動条件を満たした最初のエージェントはすぐに起動します。`startup_per_agent_delay_ms = 0` で間隔を無効にできます。手動起動と明示的なターミナルへの接続には影響しません。変更を適用するには Herdr サーバーを再起動してください。 + 公式インテグレーションからの有効なネイティブセッション参照を持つペインだけが再開できます。その他のペインは通常のシェルとして復元されます。対応する Agent と永続化の挙動については、[セッション状態と復元](/docs/session-state/)を参照してください。 ## IME カーソルトラッキング diff --git a/docs/preview/website/src/content/docs/ja/connecting-machines.mdx b/docs/preview/website/src/content/docs/ja/connecting-machines.mdx index 2c3a41cc..ef8bb5bc 100644 --- a/docs/preview/website/src/content/docs/ja/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/ja/connecting-machines.mdx @@ -7,6 +7,12 @@ description: Local と保存済み SSH マシンを 1 つの Herdr ウィンド 各マシンは独自の Herdr サーバー、セッション、プロセスを保持します。1 台への接続が切れても、ほかの接続は切れません。 +### SSH 認証の復旧 + +Linux と macOS で `remote.manage_ssh_config=true` の場合、マシン追加、サイドバー、API 操作は承認済み OpenSSH 接続を共有します。`herdr machine status [<label-or-id>] [--json]` は認証を求めず、現在の接続状態を確認します。**reachable** はリモート Herdr が利用可能という意味で、各 TUI の接続状態ではありません。ターミナルで `herdr machine reconnect <label-or-id>` を実行すると、SSH 認証後に保存済みマシンを検証します。インストールや更新は行いません。開いているクライアントは 30 秒以内に再試行し、再起動は不要です。**`! auth`**、**`! error`**、失敗後の再接続ステータスをクリックすると、最新のエラーと CLI コマンドが通知に表示されます。認証ポップアップは開きません。マシン行の展開・折りたたみは変更されません。 + +共有接続の `ControlPersist 600` はアイドル状態の 10 分を表すもので、MFA の有効期間ではありません。通信中の接続は維持されますが、企業ポリシー、SSH サーバー、ネットワークによってはそれより早く終了します。ローカル Windows の復旧はまだ対応していません。管理対象外の SSH 接続の認証は利用者の責任です。共有 ControlPath を使わずに `ssh` を実行するだけでは、Herdr の承認にはなりません。ホスト鍵の検証は緩和されず、未知または変更されたホスト鍵は引き続き **Attention** です。 + ## 接続前の確認 リモートマシンへの通常の SSH アクセスが必要です。最初に確認してください: @@ -74,18 +80,18 @@ herdr machine remove <profile-id> ## 接続の問題 - **Reconnecting:** ネットワーク中断、スリープ、SSH 障害の後は自動で再試行します。失敗が続くと待機間隔は最大 2 分まで延び、短時間だけ接続に成功してもリセットされません。1 分間安定して接続できると、次の中断では再び短い間隔で再試行します。SSH 接続はアプリケーションの通信を監視し、通信がない間はヘルスチェックを行うため、切れた接続が Online のままになりません。Local はリモート用のプローブではなく、ネイティブ接続の終了や失敗を検知します。 -- **Attention:** ホスト鍵の承認、認証、互換サーバーの準備など、バックグラウンドではできない操作が必要です。ほかのマシンは利用できます。 +- **Attention:** 認証、ホスト鍵の承認、互換サーバーの準備などが必要です。ほかのマシンは利用できます。**`! auth`** は認証失敗、**`! error`** は別の問題を示します。バッジをクリックするとエラーと CLI コマンドを表示します。折りたたみ時はどちらも `!` です。 - **保存済みマシンのファイルエラー:** カタログを読み取れない、または内容が不正な場合、現在の接続は変更しません。通知を表示して自動で読み取りを再試行します。 両端のインストールがブリッジのアイドル終了に対応している場合、Linux と macOS への保存済みマシン接続は、どちらの方向にも通信がない状態が 1 分続くとリモートブリッジを終了します。スリープ時間も含み、復帰時に期限を確認します。正常なアイドル接続ではヘルスチェックを交換するため、継続的な出力を見るためにキーを入力する必要はありません。ブリッジ終了後もリモートサーバー、セッション、ペインのプロセスは動き続けます。古いインストールとは、この追加機能なしで接続できます。 -バックグラウンド接続がプロンプトに回答したり、サーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。Attention の場合は、Herdr が表示する単独接続用のコマンドを対話型ターミナルで実行してください。例: +バックグラウンド接続がプロンプトに回答したり、サーバーのインストール、更新、再起動、ハンドオフを行ったりすることはありません。認証以外の Attention の場合は、Herdr が表示する単独接続用のコマンドを対話型ターミナルで実行してください。例: ```bash herdr --remote workbox ``` -プロファイルの接続先を使います。追加時に名前付きセッションを選んだ場合だけ、`--session <name>` も指定します。必要な確認に回答したら、クライアントを再起動して Attention の接続を再試行してください。バージョンが違うという理由だけで実行中サーバーを止めないでください。 +プロファイルの接続先を使います。名前付きセッションを選んだ場合だけ `--session <name>` も指定します。セットアップ後、開いているクライアントは自動的に再試行します。`machine reconnect` は Herdr をインストール・更新しません。バージョンが違うという理由だけで実行中サーバーを止めないでください。 認証に失敗したら、まず通常の SSH を確認します。パスフレーズ付きの鍵は、非対話型のバックグラウンド接続を始める前に `ssh-add` で読み込んでください。 diff --git a/docs/preview/website/src/content/docs/ja/integrations.mdx b/docs/preview/website/src/content/docs/ja/integrations.mdx index 6086e6b6..126ba824 100644 --- a/docs/preview/website/src/content/docs/ja/integrations.mdx +++ b/docs/preview/website/src/content/docs/ja/integrations.mdx @@ -68,37 +68,14 @@ Herdr はインテグレーションを 2 つの異なる方法で使います: カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。 -## 独自エージェントを統合する - -Herdr ペイン内で動くエージェントは、`HERDR_ENV`、`HERDR_PANE_ID`、`HERDR_BIN_PATH`、`HERDR_SOCKET_PATH` を継承します。エージェントがライフサイクルフックを提供している場合は、そのフックから Herdr の CLI を使ってセマンティックな状態を報告します: - -```bash -"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent \ - --state working -``` - -エージェントが入力待ちになったら `idle`、ユーザーの判断が必要になったら `blocked` を報告します。ブロックの説明には `--message` を使います。エージェントの終了時には、同じ source のライフサイクル権威を解放します: - -```bash -"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent -``` - -Herdr の外では何もしないように、`HERDR_ENV=1` で必要な変数が存在するときだけ報告してください。`--source` はインテグレーションごとに一意かつ固定にします。報告が順不同で届く可能性がある場合は、単調増加する `--seq` を含めてください。Herdr は同じ source から届いた古いシーケンス番号を無視します。 - -`report-agent` に `--agent-session-id` または `--agent-session-path` を含めることも、セッション識別が状態とは独立して変わる場合に `pane report-agent-session` を使うこともできます。Herdr はその参照をペイン API とエージェント API で公開します。セッションの自動復元には、それに加えて、そのエージェントの起動方法と参照先セッションの再開方法を Herdr が把握している必要があります。 - -移植可能なインテグレーションには `HERDR_BIN_PATH` と CLI ラッパーを使ってください。直接 IPC が必要なコードでは、[Socket API](/ja/docs/socket-api/) に記載された同等の `pane.report_agent`、`pane.report_agent_session`、`pane.release_agent` リクエストを送信できます。 - -[Prime Agent の組み込み Herdr レポーター](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)は実際の実装例です。Herdr 内でのみ有効になり、エージェントイベントを `working`、`idle`、`blocked` に対応付け、セッションをまたいで報告順序を維持し、終了時に権威を解放します。 - 一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Letta Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `2`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 +## 独自エージェントを統合する + +コーディングエージェントを開発している場合は、Herdr にネイティブサポートを追加せずに、自分のコードからエージェントの状態と resume コマンドを Herdr に報告できます。[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)を参照してください。 + ## Pi Pi インテグレーションをインストールします: @@ -229,6 +206,10 @@ herdr integration install opencode V2 のライフサイクル報告はペイン内の TUI で実行され、複数のペインが同じ OpenCode サーバーを共有する場合も、選択されたルートセッションにイベントを対応付けます。完了時または中断時に working 状態を解除し、未処理の権限要求やフォーム、実行失敗は blocked 状態を維持します。V2 Mini とヘッドレスクライアントは TUI プラグインを実行しないため、このライフサイクル報告は利用できません。 +V1 TUI はペインで選択したセッションとその子孫を追跡します。いずれかが実行中・再試行中なら working、権限要求や質問が未処理なら blocked を維持します。接続時に既存の状態を読み込み、キャンセル済みツールの要求が再接続後に blocked を復活させることはありません。サブエージェントの閲覧では元のセッション群を維持し、子セッションへ直接接続した場合は兄弟ではなくその子孫だけを追跡します。 + +ローカル V1 の `opencode run` と `opencode --mini` は従来のサーバーフックを維持します。この変更は Mini やヘッドレスクライアントにペイン固有のライフサイクル報告を追加するものではありません。連携の更新後は TUI と共有 OpenCode サーバーの両方を再起動し、古いサーバー報告がペインの状態を上書きしないようにしてください。 + このプラグインは、OpenCode が Herdr のペイン内で動いている間、ライフサイクル状態とセッション識別を報告します。OpenCode がセッション情報を含むイベントを発行した後、Herdr は報告されたセッション id を使って `opencode --session <id>` でペインを resume できます。プラグインがインストールされていないときは、スクリーンマニフェスト検出が引き続き利用できます。 ## Kilo Code CLI diff --git a/docs/preview/website/src/content/docs/ja/persistence-remote.mdx b/docs/preview/website/src/content/docs/ja/persistence-remote.mdx index 94bffb8b..ae695ab3 100644 --- a/docs/preview/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/ja/persistence-remote.mdx @@ -148,4 +148,4 @@ base64 の ANSI データを含む `terminal.frame` を改行区切り JSON で herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 ``` -同じフレームを出力し、標準入力から改行区切り JSON コマンドを読み取ります。`terminal.input` はテキストまたは base64 データ、`terminal.resize` は表示サイズ、`terminal.scroll` はビューポートのスクロール、`terminal.release` はコントローラーの終了を扱います。入力とリサイズを所有できるコントローラーは 1 つだけです。 +同じフレームを出力し、標準入力から改行区切り JSON コマンドを読み取ります。`terminal.input` はテキストまたは base64 データ、`terminal.resize` は表示サイズ、`terminal.scroll` はビューポートのスクロール、`terminal.mouse` はマウス報告を有効にしたアプリへのセル位置のマウスイベント、`terminal.release` はコントローラーの終了を扱います。入力とリサイズを所有できるコントローラーは 1 つだけです。 diff --git a/docs/preview/website/src/content/docs/ja/session-state.mdx b/docs/preview/website/src/content/docs/ja/session-state.mdx index 4f16bda6..a66d6a92 100644 --- a/docs/preview/website/src/content/docs/ja/session-state.mdx +++ b/docs/preview/website/src/content/docs/ja/session-state.mdx @@ -83,7 +83,7 @@ pane_history = true resume_agents_on_restore = false ``` -Herdr が resume するのは、現行の公式 Herdr インテグレーションを通じてネイティブセッション参照を報告したペインだけです。 +Herdr が resume するのは、現行の公式 Herdr インテグレーションを通じてネイティブセッション参照を報告したペインと、エージェント自身が resume コマンドを報告したペインです。[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)を参照してください。 クライアントがアタッチしてターミナルサイズとテーマのコンテキストを提供すると、Herdr は各ペインがフォーカスされるのを待たずに、ワークスペースとタブをまたいで復元対象のエージェントペインを resume します。 diff --git a/docs/preview/website/src/content/docs/ja/socket-api.mdx b/docs/preview/website/src/content/docs/ja/socket-api.mdx index 09532d5e..361f5222 100644 --- a/docs/preview/website/src/content/docs/ja/socket-api.mdx +++ b/docs/preview/website/src/content/docs/ja/socket-api.mdx @@ -99,7 +99,7 @@ herdr pane read w1:p2 --source recent --lines 50 | ワークスペース | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | | Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | | タブ | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | -| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | +| ペイン | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | | ポップアップ | `popup.close` | | レイアウト | `layout.export`、`layout.apply`、`layout.set_split_ratio` | | エージェント | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` | @@ -147,20 +147,9 @@ CLI の `herdr api snapshot` は、クライアントやエージェントが簡 ### ペイングラフィックス -ペイングラフィックスを使うと、プラグインがペイン上に画像データを配置できます。デフォルトで利用でき、サーバーで `[terminal].kitty_graphics = false` が設定されている場合は、すべてのペイングラフィックスメソッドが `feature_disabled` を返します。この設定の変更にはサーバーの再起動が必要です。`pane.graphics.info` はグラフィックスレイヤーを作成せず、接続中クライアントのセルの幅と高さをピクセル単位で返します。`pane.graphics.set` は `data_base64` の `png`、`rgb`、`rgba` データを受け取り、`pane.graphics.clear` はレイヤーを削除します。 +アプリケーションとプラグインは、ペインのターミナル出力を通じて標準の Kitty グラフィックスプロトコルを送信してください。Herdr が対応する外側ターミナルで画像の配置、クリッピング、表示状態を管理します。 -```json -{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} -{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} -{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} -``` - -フレームを繰り返し送る場合は、`pane.graphics.stream` で専用ソケットを開きます。Herdr が `ok` を返した後、フレームごとに 1 つの JSON ヘッダーと正確に `data_length` バイトの生データを送ります。ソケットが閉じるまでストリームがそのペインのグラフィックスレイヤーを所有し、同時に set、clear、stream を要求すると `stream_conflict` を返します。 - -```json -{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}} -{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} -``` +外部ソケットのペイン画像オーバーレイ API は削除されました。`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream` は利用できず、代替のソケット画像 API もありません。旧サーバーとの内部通信プロトコルの互換性は、これらの公開メソッドを復活させるものではありません。 `pane.layout` は、`workspace_id`、`tab_id`、`zoomed`、外側の `area`、`focused_pane_id`、ペインの矩形、分割の矩形/比率を含むタブレイアウトのスナップショットを返します。`pane.neighbor` と `pane.edges` は同じレイアウトスナップショットを含むので、クライアントは非公開のレイアウト状態なしに次の判断を下せます。 @@ -556,6 +545,22 @@ Herdr はローカルソケット上の改行区切り JSON を使います。Un } ``` +どちらのメソッドも、Herdr サーバーの再起動後に報告元の現在のセッションを再開するコマンドを、省略可能な `resume_argv` 配列で受け付けます。最初の要素はパスではなく単純なコマンド名でなければならず、どの要素にもアポストロフィを含めることはできません。無効な値は `invalid_resume_argv` で失敗し、その報告は適用されません。カスタムエージェントは、コマンドを付ける前に `pane.report_agent` でペインを保持している必要があり、そうでない場合は `resume_not_accepted` で失敗します。Herdr は同じ `source` と `agent` がペインを保持している間だけコマンドを保持し、そのエージェントに対する Herdr 組み込みの resume コマンドより優先します。古い Herdr はこのフィールドを無視します。 + +```json +{ + "id": "req_3", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "custom:my-agent", + "agent": "my-agent", + "agent_session_id": "...", + "resume_argv": ["my-agent", "--resume", "..."] + } +} +``` + Herdr に保存されたネイティブセッション参照があるとき、`pane.get`、`pane.list`、`agent.get`、`agent.list` は読み取り専用の `agent_session` オブジェクトを公開します: ```json diff --git a/docs/preview/website/src/content/docs/persistence-remote.mdx b/docs/preview/website/src/content/docs/persistence-remote.mdx index 4f42cef5..0ffa1406 100644 --- a/docs/preview/website/src/content/docs/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/persistence-remote.mdx @@ -155,5 +155,6 @@ herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 Control mode prints the same newline-delimited frame records and reads newline-delimited JSON commands on stdin. `terminal.input` sends text or base64 bytes, `terminal.resize` changes the controller viewport, -`terminal.scroll` scrolls the attached viewport, and `terminal.release` closes -the controller. Only one controller owns input and resize at a time. +`terminal.scroll` scrolls the attached viewport, `terminal.mouse` sends a mouse +event at a cell to applications that enabled mouse reporting, and +`terminal.release` closes the controller. Only one controller owns input and resize at a time. diff --git a/docs/preview/website/src/content/docs/session-state.mdx b/docs/preview/website/src/content/docs/session-state.mdx index b8706cd5..69bff955 100644 --- a/docs/preview/website/src/content/docs/session-state.mdx +++ b/docs/preview/website/src/content/docs/session-state.mdx @@ -81,7 +81,7 @@ Native agent session restore is enabled by default. Disable it with: resume_agents_on_restore = false ``` -Herdr only resumes panes that reported a native session reference through a current official Herdr integration. +Herdr resumes panes that reported a native session reference through a current official Herdr integration, and panes whose agent reported its own resume command. See [Add Herdr support to your agent](/docs/add-herdr-support/). After a client attaches and provides terminal size and theme context, Herdr resumes eligible restored agent panes across workspaces and tabs without waiting for each pane to be focused. diff --git a/docs/preview/website/src/content/docs/socket-api.mdx b/docs/preview/website/src/content/docs/socket-api.mdx index e6ae6a57..9e844d7e 100644 --- a/docs/preview/website/src/content/docs/socket-api.mdx +++ b/docs/preview/website/src/content/docs/socket-api.mdx @@ -103,7 +103,7 @@ Raw socket method names use dot notation: | Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.move`, `workspace.move_block`, `workspace.report_metadata`, `workspace.close` | | Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` | | Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.move`, `tab.close` | -| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.graphics.info`, `pane.graphics.set`, `pane.graphics.clear`, `pane.graphics.stream`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | +| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.process_info`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.current`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` | | Popup | `popup.close` | | Layout | `layout.export`, `layout.apply`, `layout.set_split_ratio` | | Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send_keys`, `agent.prompt`, `agent.wait`, `agent.rename`, `agent.focus`, `agent.start`, `agent.view.set`, `agent.view.clear` | @@ -176,63 +176,14 @@ Clients can treat `offset_from_bottom == 0` as at-bottom state. ### Pane graphics -Pane graphics let a plugin place image data over a pane. They are available by -default; when the server has `[terminal].kitty_graphics = false`, every pane -graphics method returns `feature_disabled`. Changing this setting requires a -server restart. Calling `pane.graphics.info` explicitly -activates capability discovery and returns the attached client's cell size, -file-frame options, pixel-mouse support, the 16-layer limit, and `pane_visible`. -`pane_visible` is true only when the target is in the active workspace and tab -and is not hidden by zoom. Short-lived UI modes do not change it. - -`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` accept an -optional `layer_id` (default `primary`). Set and stream also accept `z_index`; -layers are placed in stable `(z_index, layer_id)` order. Each stream exclusively owns its layer, and -closing it removes that layer. Inline frames accept `png`, `rgb`, `rgba`, or -`bgra`; BGRA is normalized once to owned RGBA. Herdr advances the host cache -one image transaction per render pass, so arbitrary layer sets progress without -an aggregate frame. Client transport keeps each transaction within its 32 MiB -wire limit. - -```json -{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} -{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} -{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} -``` - -For repeated frames, open a dedicated socket with `pane.graphics.stream`. After -Herdr replies with `ok`, send one JSON header and then exactly `data_length` raw -bytes per inline frame. Concurrent operations on that layer return -`stream_conflict`. - -```json -{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1","z_index":0}} -{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} -``` - -When `pane.graphics.info` advertises `file_frame_transport: "direct-kitty"`, an -eligible local Ghostty, kitty, or WezTerm client may submit an immutable private -`rgba` or `bgra` file with `file.path`, `sequence`, and `revision`. Direct Kitty -file transport is reserved for the default `primary` page layer; named secondary -layers use owned inline RGBA. BGRA is always copied, swizzled, and rendered -inline. Herdr replies with a `pane_graphics_frame_ack` only after the terminal -accepts the file, or after a safe owned inline fallback is installed. Confirmed -file-transport failure disables direct files for that client connection without -disabling exact pixel mouse. A timeout or client loss closes the stream without -acknowledging source reuse. Clients that cannot negotiate direct file transport -remain on the owned inline fallback. - -Direct files are always complete canonical `width * height * 4` RGBA frames. -`file_frame_max_bytes` is the limit that remains eligible for owned inline fallback. -Primary-layer RGBA files may use the larger `file_frame_direct_max_bytes` limit when -`file_frame_transport` is available. Frames above the fallback limit are acknowledged -only when the terminal accepts the direct transfer; rejection closes the stream. If a -frame cannot use owned inline fallback while its pane is temporarily hidden or cannot be -placed during a redraw, Herdr uploads the image without displaying it and replays its -placement when the pane becomes visible again. -`file_frame_damage: true` means Herdr accepts optional damage metadata for -producer-side canonical-ring efficiency; it still copies or presents the full file. -Resize and full redraw replay placements without retransmitting pixels. +Applications and plugins should emit the standard Kitty graphics protocol through +their pane's terminal output. Herdr manages image placement, clipping, and +visibility in compatible outer terminals. + +The external socket pane-overlay API has been removed: `pane.graphics.info`, +`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` are no +longer available. There is no replacement socket image API. Internal wire +compatibility with older servers does not restore these public methods. `pane.layout` returns the tab layout snapshot with `workspace_id`, `tab_id`, `zoomed`, outer `area`, `focused_pane_id`, pane rects, and split rects/ratios. @@ -736,6 +687,22 @@ Session-only official integrations report native session references with `pane.r } ``` +Both methods accept an optional `resume_argv` array with the command that resumes the reporter's current session after a Herdr server restart. Its first element must be a plain command name, not a path, and no element may contain an apostrophe; invalid values fail with `invalid_resume_argv` and the report is not applied. A custom agent must hold the pane through `pane.report_agent` before it can attach a command; otherwise the request fails with `resume_not_accepted`. Herdr keeps the command only while the same `source` and `agent` hold the pane, and it takes precedence over Herdr's built-in resume command for that agent. Older Herdr versions ignore the field. + +```json +{ + "id": "req_3", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "custom:my-agent", + "agent": "my-agent", + "agent_session_id": "...", + "resume_argv": ["my-agent", "--resume", "..."] + } +} +``` + `pane.get`, `pane.list`, `agent.get`, and `agent.list` expose a read-only `agent_session` object when Herdr has a stored native session reference: ```json diff --git a/docs/preview/website/src/content/docs/zh-cn/add-herdr-support.mdx b/docs/preview/website/src/content/docs/zh-cn/add-herdr-support.mdx new file mode 100644 index 00000000..827fd233 --- /dev/null +++ b/docs/preview/website/src/content/docs/zh-cn/add-herdr-support.mdx @@ -0,0 +1,117 @@ +--- +title: 为你的智能体添加 Herdr 支持 +description: 在不为 Herdr 添加原生支持的情况下,从你自己的代码向 Herdr 上报编码智能体的状态和恢复命令。 +--- + +如果你在开发编码智能体,只靠你自己的代码就能让它在 Herdr 中获得一等支持。不需要向 Herdr 提交拉取请求,也不需要等待 Herdr 发布新版本。 + +智能体向 Herdr 上报后,用户会得到: + +- 在侧边栏和 `herdr agent list` 中显示的智能体名称以及 `idle`、`working` 或 `blocked` 状态 +- 智能体完成或需要用户决策时的通知 +- `herdr agent wait` 以及其他等待智能体状态的自动化 +- Herdr 服务器重启后,同一个窗格中恢复同一个会话 + +对于厂商没有提供集成的智能体,由 Herdr 维护集成。如果你维护自己的智能体,自己掌握集成就可以按自己的发布节奏修复问题。 + +## 工作方式 + +Herdr 窗格中的每个进程都会继承这些环境变量: + +| 变量 | 含义 | +| --- | --- | +| `HERDR_ENV` | 在 Herdr 中为 `1` | +| `HERDR_PANE_ID` | 智能体所在的窗格 | +| `HERDR_BIN_PATH` | 管理这个窗格的 Herdr 可执行文件 | +| `HERDR_SOCKET_PATH` | Herdr 的 API 套接字 | + +你的智能体通过 `"$HERDR_BIN_PATH"` 发送三类上报: + +1. **状态。** 每当 `idle`、`working` 或 `blocked` 发生变化时上报。 +2. **恢复命令。** 上报重新打开当前会话的命令。 +3. **释放。** 智能体退出时释放窗格。 + +仅在 `HERDR_ENV=1` 且其他变量都存在时上报。在 Herdr 之外,集成不应执行任何操作。 + +## 上报状态 + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state working \ + --seq 1 +``` + +- 一轮开始时上报 `working`,可以接收输入时上报 `idle`,需要用户做决定时上报 `blocked`。可以用 `--message` 说明阻塞原因。 +- `--agent` 是用户看到的名称。请使用你自己智能体的名称,不要使用 Herdr 已经支持的智能体的名称。 +- `--source` 用于标识你的集成。保持稳定且唯一。不要以 `herdr:` 开头,这个前缀由 Herdr 自己的集成使用。 +- `--seq` 可以省略,但建议加上。它必须随来自你的来源的每次上报递增,跨会话和智能体重启也要继续递增。时间戳就很合适。Herdr 会忽略编号不大于上次已接受编号的上报,所以迟到或乱序的上报不会覆盖更新的状态。 + +## 上报恢复命令 + +把恢复当前会话的命令放在 `--` 之后。带上该会话需要的选项,例如模型或权限模式,这样恢复后的会话行为一致: + +```bash +"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --state idle \ + --seq 2 \ + --agent-session-id "$SESSION_ID" \ + -- my-agent --resume "$SESSION_ID" --model my-model +``` + +命令可以附在任何一次状态上报中,只有会话变化时也可以用 `pane report-agent-session` 发送。每当智能体切换会话时,请重新上报。 + +Herdr 服务器重启后,Herdr 会在同一目录中打开该窗格并在其中运行这条命令。命令必须遵守以下规则: + +- 第一个词是用户 `PATH` 上的普通命令名,例如 `my-agent`,不能是路径。 +- 任何参数都不能包含撇号。 +- 你的来源必须先持有该窗格,所以请在发送命令之前或同时用 `report-agent` 上报状态。否则 Herdr 会返回 `resume_not_accepted`。 + +违反前两条规则的命令会被 Herdr 以 `invalid_resume_argv` 拒绝,该次上报也不会生效。用户可以用 `[session] resume_agents_on_restore = false` 关闭恢复。 + +## 退出时释放 + +```bash +"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ + --source my-agent \ + --agent my-agent \ + --seq 3 +``` + +释放会立即从窗格中清除智能体的名称、状态和恢复命令。只在用户真正退出时释放。如果智能体在同一个进程中用一个会话替换另一个会话,请上报新会话,而不是释放。 + +如果智能体退出时没有释放,Herdr 会在窗格回到空闲的 shell 提示符后察觉,并清除该智能体及其恢复命令。通常需要一两秒。这只是兜底机制,不能代替释放。 + +## 不要拖慢智能体 + +- 不要让 Herdr 拖慢你的智能体。在后台或用较短的超时发送上报,并忽略失败。 +- 只发送最新状态。如果一次上报正在发送时积累了多个变化,丢弃较旧的那些。 +- 旧版 Herdr 会忽略恢复命令,其他功能照常工作。 + +## 直接使用套接字 + +`HERDR_BIN_PATH` 和 CLI 是可移植的选择,包括在 Windows 上。如果你的智能体需要直接 IPC,可以发送 [Socket API](/zh-cn/docs/socket-api/) 中说明的等效 `pane.report_agent`、`pane.report_agent_session` 和 `pane.release_agent` 请求。恢复命令放在 `resume_argv` 数组中。 + +## 检查你的集成 + +在 Herdr 窗格中启动你的智能体,然后查看 Herdr 看到了什么: + +```bash +herdr pane get "$HERDR_PANE_ID" +herdr agent list +``` + +要在不影响日常会话的情况下测试恢复,请使用单独的命名会话: + +1. 启动 `herdr --session my-agent-test`,然后在一个窗格中运行你的智能体。 +2. 用 `herdr session stop my-agent-test` 停止该会话。 +3. 再次启动 `herdr --session my-agent-test`。窗格应当运行你的恢复命令,智能体应当再次上报。 + +## 示例 + +[Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实的集成。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放窗格。 + +如果想在不改变状态的情况下改变智能体的显示方式,例如标题或自定义状态文字,请参阅[自定义状态标签](/zh-cn/docs/integrations/#自定义状态标签)。 diff --git a/docs/preview/website/src/content/docs/zh-cn/agents.mdx b/docs/preview/website/src/content/docs/zh-cn/agents.mdx index bc749add..13ec4998 100644 --- a/docs/preview/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/agents.mdx @@ -89,6 +89,14 @@ herdr agent explain --file screen.txt --agent codex --json Herdr 可以在 tmux 作为外层终端环境时运行。智能体检测不会检查在 Herdr 窗格内启动的 tmux 会话。如果某个 shell 框架在 Herdr 内自动进入 tmux,Herdr 看到的窗格进程就是 `tmux`,而不是它背后的智能体。 +如果你的 shell 在 `TMUX` 未设置时自动连接 tmux,请更新 shell 启动条件以排除 Herdr 窗格。从 Herdr 窗格内重新连接外层 tmux 会话可能导致终端尺寸递归缩小、画面闪烁。在现有条件中加入 `HERDR_ENV` 检查,并确保它在运行 tmux 之前生效: + +```zsh +if [[ -z ${TMUX:-} && ${HERDR_ENV:-} != 1 ]]; then + tmux new-session -A -s my-session +fi +``` + ## 状态汇总 侧边栏会把状态向上汇总。 @@ -106,7 +114,7 @@ herdr integration install claude herdr integration status ``` -每个受支持的智能体都有自己的集成名称和行为。按智能体的细节和完整安装列表见[集成](/zh-cn/docs/integrations/)。如果你正在构建智能体,[自定义集成指南](/zh-cn/docs/integrations/#集成你自己的智能体)介绍了如何在不为 Herdr 添加原生支持的情况下上报生命周期状态。 +每个受支持的智能体都有自己的集成名称和行为。按智能体的细节和完整安装列表见[集成](/zh-cn/docs/integrations/)。如果你正在构建智能体,[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)介绍了如何在不为 Herdr 添加原生支持的情况下上报生命周期状态和恢复命令。 ## 自定义智能体标签 diff --git a/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx index 011efbd3..ae5c3ab4 100644 --- a/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/cli-reference.mdx @@ -62,7 +62,7 @@ herdr machine remove <profile-id> 更改会自动应用到已打开的本地 Herdr 客户端,通常在一秒内生效。新增或启用的机器在后台连接;重命名不会重新连接。移除或禁用只会断开该机器,远程会话仍会继续运行。移除正在查看的机器会返回 Local;如果 Local 不可用,则显示不可用状态,直到重新连接。每份配置在客户端状态文件中保存一个不透明 ID、标签、SSH 目标、明确的远程会话名和启用状态。Herdr 不保存密码、私钥或其他 SSH 凭据。 -自动连接和重连均为非交互式。如果主机密钥、密码、密钥口令、MFA、安装、更新或重启需要批准,机器会显示 Attention,而不会打开隐藏的提示。请运行 Herdr 显示的独立连接命令,例如 `herdr --remote workbox`,在前台完成设置后重启客户端。仅当配置指向命名会话时,才加上 `--session <name>`。 +`herdr machine status [<label-or-id>] [--json]` 可非交互式检查 SSH 和远程 Herdr。`reachable` 表示本次检查成功,不代表 TUI 当前已连接。在终端运行 `herdr machine reconnect <label-or-id>` 可认证并验证连接,不会安装或更新 Herdr。已打开的客户端会在 30 秒内重试。点击 `! auth` 或 `! error` 可查看最新错误和 CLI 命令;侧边栏折叠时显示 `!`。平台限制请参阅[恢复 SSH 认证](/zh-cn/docs/connecting-machines/#恢复-ssh-认证)。需要远程设置时使用 `herdr --remote workbox`,仅在命名会话时添加 `--session <name>`。 工作区、标签页、窗格 ID 和智能体名称仅在所属服务器内有效。UI 中的选择不会改变 CLI 的目标。使用全局**前缀** `--machine <label-or-id>` 将 API 命令发送到已保存的 SSH 机器: @@ -258,7 +258,8 @@ herdr pane report-agent <pane_id> \ [--message TEXT] \ [--seq N] \ [--agent-session-id ID] \ - [--agent-session-path PATH] + [--agent-session-path PATH] \ + [-- RESUME_COMMAND...] herdr pane report-agent-session <pane_id> \ --source ID \ @@ -266,7 +267,8 @@ herdr pane report-agent-session <pane_id> \ [--seq N] \ [--agent-session-id ID] \ [--agent-session-path PATH] \ - [--session-start-source SOURCE] + [--session-start-source SOURCE] \ + [-- RESUME_COMMAND...] herdr pane release-agent <pane_id> \ --source ID \ @@ -276,6 +278,8 @@ herdr pane release-agent <pane_id> \ `report-agent-session` 只更新原生会话身份,不上报生命周期状态。`release-agent` 在智能体进程退出时结束该来源的生命周期权威。 +`--` 之后的参数是服务器重启后 Herdr 为恢复此会话而运行的命令。第一个参数必须是 `PATH` 上的普通命令名,并且任何参数都不能包含撇号。 + 当官方集成上报了原生会话引用时,`pane get`、`pane list`、`agent get` 和 `agent list` 会包含一个只读的 `agent_session` 对象。没有存储原生会话引用时,该字段被省略。 当 Herdr 能解析控制窗格的前台进程的 cwd 时,这些命令会包含 `foreground_cwd`。已有的 `cwd` 字段仍然是用于标签和 follow-cwd 行为的窗格/工作区 cwd。 @@ -351,7 +355,7 @@ herdr terminal title clear ``` 从直接附加中用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 -`terminal session control` 为窗格、终端或智能体打开可写的实时终端流,输出与 observe 相同的逐行 JSON `terminal.frame` 和 `terminal.closed`,并从标准输入读取 `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.release` 命令。同一终端同时只能有一个控制方,可用 `--takeover` 接管。 +`terminal session control` 为窗格、终端或智能体打开可写的实时终端流,输出与 observe 相同的逐行 JSON `terminal.frame` 和 `terminal.closed`,并从标准输入读取 `terminal.input`、`terminal.resize`、`terminal.scroll`、`terminal.mouse`、`terminal.release` 命令。同一终端同时只能有一个控制方,可用 `--takeover` 接管。`terminal.mouse` 在从 0 开始计数的单元格位置发送一个鼠标事件,例如 `{"type":"terminal.mouse","action":"down","button":"left","column":12,"row":5}`。`action` 为 `down`、`up`、`drag` 或 `move`;`button` 为 `left`(默认)、`right` 或 `middle`;`modifiers` 可省略,由 Shift = 1、Ctrl = 2、Alt = 4 组合(默认 0)。一次点击就是先 `down` 再 `up`。Herdr 会按应用的鼠标模式编码事件;应用未开启鼠标报告时直接丢弃,因此普通 shell 不会收到多余输入。`move` 只会发给追踪全部鼠标移动的应用。 `terminal session observe` 为相同目标打开只读实时流,输出包含 base64 ANSI 字节的逐行 JSON `terminal.frame`,服务器关闭流时输出 `terminal.closed`。多个观察方可以同时查看终端,不获取输入、调整尺寸、滚动或接管权限。Linux/macOS 上,若套接字写入连续 30 秒没有进展,观察方会被断开;此时可能没有最后的 `terminal.closed` 记录,但窗格和其他客户端继续运行。安静、没有输出的窗格不会触发此超时。 diff --git a/docs/preview/website/src/content/docs/zh-cn/configuration.mdx b/docs/preview/website/src/content/docs/zh-cn/configuration.mdx index 9c1d5b2d..8b8738d6 100644 --- a/docs/preview/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/configuration.mdx @@ -528,8 +528,11 @@ kitty_graphics = false ```toml [session] resume_agents_on_restore = true +startup_per_agent_delay_ms = 100 ``` +为减少启动时的资源争用,自动恢复默认每隔 100 毫秒启动一个智能体。第一个满足条件的智能体会立即启动。设置 `startup_per_agent_delay_ms = 0` 可禁用间隔。手动启动和显式连接终端不受影响。修改后需重启 Herdr 服务器。 + 只有通过官方集成获得有效原生会话引用的窗格才能恢复;其他窗格会作为普通 shell 恢复。有关受支持的智能体和持久化行为,请参阅[会话状态与恢复](/docs/session-state/)。 ## IME 光标跟踪 diff --git a/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx b/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx index 4175d5ee..722adeed 100644 --- a/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/connecting-machines.mdx @@ -7,6 +7,12 @@ description: 在一个 Herdr 窗口中使用 Local 和已保存的 SSH 机器, 每台机器保留自己的 Herdr 服务器、会话和进程。一台机器断线不会影响其他连接。 +### 恢复 SSH 认证 + +在 Linux 和 macOS 上,启用 `remote.manage_ssh_config=true` 后,添加机器、侧边栏和 API 操作共享已批准的 OpenSSH 连接。运行 `herdr machine status [<label-or-id>] [--json]` 可进行新的非交互式检查。**reachable** 表示远程 Herdr 当前可用,不代表每个 TUI 都已连接。在终端运行 `herdr machine reconnect <label-or-id>` 完成原生 SSH 认证并验证已保存的机器;该命令不会安装或更新 Herdr。已打开的客户端会在 30 秒内重试,无需重启。点击 **`! auth`**、**`! error`** 或失败后的重连状态,会显示包含最新错误及 CLI 命令的通知,不会打开认证弹窗。机器行其余部分仍用于展开或折叠。 + +共享连接使用 `ControlPersist 600`,表示空闲 10 分钟,并不表示 MFA 有效期。活动连接可以继续保持,但企业策略、SSH 服务器或网络可能更早终止连接。Windows 本地恢复尚未支持。未受管理的 SSH 连接由你负责认证;单独运行 `ssh` 不会授予 Herdr 批准,除非它使用共享 ControlPath。主机密钥检查不会放宽;未知或已更改的主机密钥仍显示为 **Attention**。 + ## 连接前的准备 你需要能够通过普通 SSH 访问远程机器。先验证: @@ -74,18 +80,18 @@ herdr machine remove <profile-id> ## 连接问题 - **Reconnecting:** 网络中断、睡眠或 SSH 故障后,Herdr 会自动重试。连续失败会将间隔增加到最多两分钟,短暂连接成功不会重置间隔。连接稳定一分钟后,下一次中断才会恢复快速重试。SSH 连接会监测应用层活动并在空闲时探测,避免已断开的连接一直显示 Online。Local 使用本地连接关闭或失败来判断断线,而不是远程健康探测。 -- **Attention:** 目标需要无法在后台完成的操作,例如批准主机密钥、认证或准备兼容服务器。其他机器仍可使用。 +- **Attention:** 目标需要认证、批准主机密钥或准备兼容服务器等操作。其他机器仍可使用。**`! auth`** 表示认证失败,**`! error`** 表示其他问题。点击徽章可查看错误和 CLI 命令。侧边栏折叠时两者都显示为 `!`。 - **保存机器文件错误:** 目录文件无法读取或内容无效时,当前连接保持不变。Herdr 会显示通知并自动重试读取。 当两端都支持空闲桥接清理时,连接 Linux/macOS 的保存机器会在双向均无流量一分钟后关闭远程桥接。睡眠时间也计入期限,并在唤醒时检查。正常的空闲连接会交换健康检查,持续查看输出无需键盘输入。清理不会停止远程 Herdr 服务器、会话或窗格进程。旧安装仍可兼容连接,只是不使用这项可选清理功能。 -后台连接不会回答提示,也不会安装、更新、重启或交接服务器。对于 Attention,请在交互式终端运行 Herdr 显示的独立设置命令,例如: +后台连接不会回答提示,也不会安装、更新、重启或交接服务器。对于非认证类 Attention,请在交互式终端运行 Herdr 显示的独立设置命令,例如: ```bash herdr --remote workbox ``` -使用配置中的目标。如果添加时选择了命名会话,再加上可选的 `--session <name>`。完成批准提示后,重启客户端以重试 Attention 连接。不要仅因为版本与客户端不同就停止运行中的服务器。 +使用配置中的目标。如果添加时选择了命名会话,再加上 `--session <name>`。完成设置后,已打开的客户端会自动重试。`machine reconnect` 不会安装或更新 Herdr。不要仅因为版本与客户端不同就停止运行中的服务器。 认证失败时,先检查普通 SSH。对于有口令的私钥,请在启动 Herdr 的非交互式后台连接前用 `ssh-add` 加载。 diff --git a/docs/preview/website/src/content/docs/zh-cn/integrations.mdx b/docs/preview/website/src/content/docs/zh-cn/integrations.mdx index 5eb1f322..0fc3d2c0 100644 --- a/docs/preview/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/integrations.mdx @@ -68,37 +68,14 @@ Herdr 以两种不同方式使用集成: 自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。 -## 集成你自己的智能体 - -在 Herdr 窗格中运行的智能体会继承 `HERDR_ENV`、`HERDR_PANE_ID`、`HERDR_BIN_PATH` 和 `HERDR_SOCKET_PATH`。如果智能体提供生命周期钩子,可从这些钩子通过 Herdr CLI 上报语义状态: - -```bash -"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent \ - --state working -``` - -当智能体等待输入时上报 `idle`,需要用户决策时上报 `blocked`。用 `--message` 描述阻塞原因。智能体退出时,释放同一来源的生命周期权威: - -```bash -"$HERDR_BIN_PATH" pane release-agent "$HERDR_PANE_ID" \ - --source custom:my-agent \ - --agent my-agent -``` - -仅在 `HERDR_ENV=1` 且必要变量存在时上报,这样集成在 Herdr 外不会执行任何操作。保持 `--source` 稳定且对该集成唯一。如果上报可能乱序到达,请加入严格递增的 `--seq`;Herdr 会忽略同一来源的旧序列号。 - -你可以在 `report-agent` 中加入 `--agent-session-id` 或 `--agent-session-path`,也可以在会话身份独立于状态变化时使用 `pane report-agent-session`。Herdr 会通过窗格和智能体 API 暴露该引用。自动恢复会话还要求 Herdr 知道如何启动该智能体并恢复所引用的会话。 - -要实现可移植的集成,请使用 `HERDR_BIN_PATH` 和 CLI 包装命令。需要直接 IPC 的代码可以发送 [Socket API](/zh-cn/docs/socket-api/) 中说明的等效 `pane.report_agent`、`pane.report_agent_session` 和 `pane.release_agent` 请求。 - -[Prime Agent 内置的 Herdr 上报器](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts)是一个真实实现示例。它只在 Herdr 中启用,将智能体事件映射为 `working`、`idle` 和 `blocked`,跨会话保持上报顺序,并在退出时释放权威。 - 一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Letta Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `2`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 +## 集成你自己的智能体 + +如果你在开发编码智能体,可以在不为 Herdr 添加原生支持的情况下,从你自己的代码向 Herdr 上报它的状态和恢复命令。参阅[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)。 + ## Pi 安装 Pi 集成: @@ -229,6 +206,10 @@ herdr integration install opencode V2 生命周期上报在窗格本地的 TUI 中运行。即使多个窗格共享同一个 OpenCode 服务端,事件也只归属于该 TUI 选中的根会话。完成和中断会清除工作状态;待处理的权限请求、表单以及执行失败会使窗格保持阻塞。V2 Mini 和无界面客户端不运行 TUI 插件,因此不提供此生命周期上报。 +V1 TUI 跟踪窗格选中的会话及其后代。只要其中任何会话仍在忙碌或重试,就保持 working;存在待处理的权限请求或问题时保持 blocked。连接时会读取已有活动,已取消工具的请求不会在重新连接后再次变成阻塞项。查看子代理时保留原来的会话范围;直接连接子会话时只跟踪它及其后代,不包括兄弟会话。 + +本地 V1 的 `opencode run` 和 `opencode --mini` 保留原有服务端钩子;此变更不会为 Mini 或无界面客户端添加窗格本地的生命周期上报。更新集成后,请同时重启共享 OpenCode 服务器及其 TUI,避免旧服务端上报覆盖窗格本地状态。 + 该插件在 OpenCode 运行于 Herdr 窗格内时上报生命周期状态和会话身份。在 OpenCode 发出携带会话信息的事件后,Herdr 可以用上报的会话 id 通过 `opencode --session <id>` 恢复该窗格。插件未安装时,屏幕清单检测仍然可用。 ## Kilo Code CLI diff --git a/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx index ddd0b0fb..6b0be4d8 100644 --- a/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -148,4 +148,4 @@ herdr terminal session observe w1:p1 --cols 120 --rows 40 herdr terminal session control w1:p1 --takeover --cols 120 --rows 40 ``` -控制模式输出相同的帧,并从标准输入读取逐行 JSON 命令。`terminal.input` 发送文本或 base64 数据,`terminal.resize` 更改视口尺寸,`terminal.scroll` 滚动视口,`terminal.release` 关闭控制器。同一时间只有一个控制器能拥有输入和调整尺寸的权限。 +控制模式输出相同的帧,并从标准输入读取逐行 JSON 命令。`terminal.input` 发送文本或 base64 数据,`terminal.resize` 更改视口尺寸,`terminal.scroll` 滚动视口,`terminal.mouse` 向已开启鼠标报告的应用发送单元格位置的鼠标事件,`terminal.release` 关闭控制器。同一时间只有一个控制器能拥有输入和调整尺寸的权限。 diff --git a/docs/preview/website/src/content/docs/zh-cn/session-state.mdx b/docs/preview/website/src/content/docs/zh-cn/session-state.mdx index 9be84060..6cb83f7d 100644 --- a/docs/preview/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/session-state.mdx @@ -83,7 +83,7 @@ pane_history = true resume_agents_on_restore = false ``` -Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引用的窗格。 +Herdr 会恢复通过当前官方 Herdr 集成上报了原生会话引用的窗格,以及智能体自己上报了恢复命令的窗格。参阅[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)。 当客户端连接并提供终端尺寸和主题上下文后,Herdr 会跨工作区和标签页恢复符合条件的智能体窗格,不需要等每个窗格被聚焦。 diff --git a/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx b/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx index 3aae7131..e26c259e 100644 --- a/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/preview/website/src/content/docs/zh-cn/socket-api.mdx @@ -99,7 +99,7 @@ herdr pane read w1:p2 --source recent --lines 50 | 工作区 | `workspace.create`、`workspace.list`、`workspace.get`、`workspace.focus`、`workspace.rename`、`workspace.move`、`workspace.move_block`、`workspace.report_metadata`、`workspace.close` | | Worktree | `worktree.list`、`worktree.create`、`worktree.open`、`worktree.remove` | | 标签页 | `tab.create`、`tab.list`、`tab.get`、`tab.focus`、`tab.rename`、`tab.move`、`tab.close` | -| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear`、`pane.graphics.stream`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | +| 窗格 | `pane.split`、`pane.swap`、`pane.move`、`pane.zoom`、`pane.layout`、`pane.process_info`、`pane.neighbor`、`pane.edges`、`pane.focus_direction`、`pane.resize`、`pane.list`、`pane.current`、`pane.get`、`pane.rename`、`pane.send_text`、`pane.send_keys`、`pane.send_input`、`pane.read`、`pane.report_agent`、`pane.report_agent_session`、`pane.report_metadata`、`pane.clear_agent_authority`、`pane.release_agent`、`pane.close`、`pane.wait_for_output` | | 弹窗 | `popup.close` | | 布局 | `layout.export`、`layout.apply`、`layout.set_split_ratio` | | 智能体 | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send_keys`、`agent.prompt`、`agent.wait`、`agent.rename`、`agent.focus`、`agent.start` | @@ -147,20 +147,9 @@ CLI 的 `herdr api snapshot` 会把当前 `session.snapshot` 响应输出为 JSO ### 窗格图形 -窗格图形允许插件在窗格上放置图像数据。该功能默认可用;当服务器设置 `[terminal].kitty_graphics = false` 时,所有窗格图形方法都返回 `feature_disabled`。更改此设置需要重启服务器。调用 `pane.graphics.info` 会返回所连接客户端的单元格像素宽度和高度,但不会创建图形层。`pane.graphics.set` 接受 `data_base64` 中的 `png`、`rgb` 或 `rgba` 数据,`pane.graphics.clear` 删除该图层。 +应用和插件应通过窗格的终端输出发送标准 Kitty 图形协议。Herdr 在兼容的外层终端中管理图像的位置、裁剪和可见性。 -```json -{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}} -{"id":"graphics_set","method":"pane.graphics.set","params":{"pane_id":"w1:p1","format":"png","image_width":800,"image_height":600,"data_base64":"...","placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}} -{"id":"graphics_clear","method":"pane.graphics.clear","params":{"pane_id":"w1:p1"}} -``` - -要重复发送帧,用 `pane.graphics.stream` 打开专用 socket。Herdr 回复 `ok` 后,每一帧发送一个 JSON 头,再发送恰好 `data_length` 字节的原始数据。socket 关闭前,该流拥有对应窗格的图形层;并发 set、clear 或 stream 请求返回 `stream_conflict`。 - -```json -{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}} -{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}} -``` +外部 socket 窗格图像叠加 API 已移除:`pane.graphics.info`、`pane.graphics.set`、`pane.graphics.clear` 和 `pane.graphics.stream` 不再可用,也没有替代的 socket 图像 API。与旧服务器的内部传输协议兼容性不会恢复这些公开方法。 `pane.layout` 返回标签页布局快照,包含 `workspace_id`、`tab_id`、`zoomed`、外层 `area`、`focused_pane_id`、窗格矩形和分割矩形/比例。`pane.neighbor` 和 `pane.edges` 也包含同一份布局快照,让客户端不需要私有布局状态就能做出下一步决策。 @@ -552,6 +541,22 @@ Herdr 在本地 socket 上使用换行分隔的 JSON。在 Unix 上,那个 socke } ``` +两个方法都接受可选的 `resume_argv` 数组,内容是 Herdr 服务器重启后恢复上报者当前会话的命令。第一个元素必须是普通命令名而不是路径,并且任何元素都不能包含撇号;无效值会以 `invalid_resume_argv` 失败,该次上报不会生效。自定义智能体必须先通过 `pane.report_agent` 持有窗格才能附加命令,否则请求会以 `resume_not_accepted` 失败。Herdr 只在同一 `source` 和 `agent` 持有窗格时保留该命令,并且它优先于 Herdr 为该智能体内置的恢复命令。旧版 Herdr 会忽略这个字段。 + +```json +{ + "id": "req_3", + "method": "pane.report_agent_session", + "params": { + "pane_id": "w1:p1", + "source": "custom:my-agent", + "agent": "my-agent", + "agent_session_id": "...", + "resume_argv": ["my-agent", "--resume", "..."] + } +} +``` + Herdr 存有原生会话引用时,`pane.get`、`pane.list`、`agent.get` 和 `agent.list` 暴露一个只读的 `agent_session` 对象: ```json diff --git a/docs/preview/website/src/data/config-reference.json b/docs/preview/website/src/data/config-reference.json index ece9a833..eb7e08d4 100644 --- a/docs/preview/website/src/data/config-reference.json +++ b/docs/preview/website/src/data/config-reference.json @@ -439,7 +439,7 @@ "key": "terminal.kitty_graphics", "type": "boolean", "default": "true", - "description": "Render pane images in Kitty graphics-compatible outer terminals. Set false to disable graphics rendering and the pane graphics API. Restart the server or reattach the client after changing it. In remote sessions, server configuration controls parsing and API availability while local client configuration controls outer-terminal output." + "description": "Render pane images in Kitty graphics-compatible outer terminals. Applications use the standard Kitty graphics protocol through pane terminal output; the external socket pane-overlay API is no longer available. Set false to disable graphics rendering. Restart the server or reattach the client after changing it. In remote sessions, server configuration controls Kitty graphics parsing while local client configuration controls outer-terminal output." } ] }, @@ -477,9 +477,9 @@ "keys": [ { "key": "keys.prefix", - "type": "string", + "type": "keybinding", "default": "\"ctrl+b\"", - "description": "Prefix key to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\")." + "description": "Prefix key(s) to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\", or an array to accept several)." }, { "key": "keys.help", @@ -1429,6 +1429,12 @@ "type": "boolean", "default": "true", "description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session." + }, + { + "key": "session.startup_per_agent_delay_ms", + "type": "integer", + "default": "100", + "description": "Milliseconds between automatic agent restores after a server restart. The first agent starts without this delay; 0 disables spacing. Does not delay manual launches or explicit terminal attaches. Restart the server to apply." } ] }, From d5680d84fd1df3b592424eae5c50d54642f0727d Mon Sep 17 00:00:00 2001 From: JJ Liebig <jonathan.liebig@gmail.com> Date: Mon, 28 Sep 2026 17:16:27 +0200 Subject: [PATCH 90/99] fix: capture Windows host default colors at startup (#4710) refs #1530 refs #4369 --- src/client/input.rs | 27 ++- src/client/input/windows_vti.rs | 303 +++++++++++++++++++++++++++-- src/client/mod.rs | 13 +- src/client/shell_runtime.rs | 110 +++++++++++ src/client/terminal_geometry.rs | 4 - src/client/terminal_setup.rs | 5 +- src/client/tests/mod.rs | 5 - src/protocol/wire.rs | 17 ++ src/raw_input.rs | 329 +++++++++++++++++++++++++++++++- 9 files changed, 773 insertions(+), 40 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index 1e308d54..ab46e788 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -51,14 +51,13 @@ pub fn stdin_reader_loop( #[cfg(windows)] { let _ = ( - host_color_query_sent, host_theme_query_pending, host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, ); let _ = (host_escape_disambiguation_active, initial_host_input); - windows_stdin_reader_loop(event_tx, should_quit); + windows_stdin_reader_loop(event_tx, should_quit, host_color_query_sent); } #[cfg(unix)] @@ -397,6 +396,7 @@ fn idle_flush_timeout_ms( fn windows_stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, + host_color_query_sent: bool, ) { if !super::windows_vti_input_backend_enabled() { windows_vti::trace_input_transport("reader=crossterm"); @@ -405,7 +405,12 @@ fn windows_stdin_reader_loop( match windows_vti::console_input_handle() { Ok(handle) => { windows_vti::trace_input_transport("reader=windows-console"); - windows_vti::raw_console_reader_loop(handle, event_tx, should_quit); + windows_vti::raw_console_reader_loop( + handle, + event_tx, + should_quit, + host_color_query_sent, + ); } _ => { windows_vti::trace_input_transport("reader=crossterm-fallback"); @@ -650,8 +655,20 @@ fn windows_client_input_event_from_raw( crate::raw_input::RawInputEvent::OuterFocusLost => { Some(crate::protocol::ClientInputEvent::FocusLost) } - crate::raw_input::RawInputEvent::HostDefaultColor { .. } - | crate::raw_input::RawInputEvent::HostPaletteColors { .. } + crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { + Some(crate::protocol::ClientInputEvent::HostDefaultColor { + kind: match kind { + crate::terminal_theme::DefaultColorKind::Foreground => { + crate::protocol::ClientHostDefaultColorKind::Foreground + } + crate::terminal_theme::DefaultColorKind::Background => { + crate::protocol::ClientHostDefaultColorKind::Background + } + }, + color: color.into(), + }) + } + crate::raw_input::RawInputEvent::HostPaletteColors { .. } | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_) | crate::raw_input::RawInputEvent::HostCellSizeReport { .. } | crate::raw_input::RawInputEvent::Unsupported => None, diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 0deb3185..c4642885 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -20,9 +20,16 @@ pub(super) fn raw_console_reader_loop( handle: windows_sys::Win32::Foundation::HANDLE, event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, + host_color_query_sent: bool, ) { let mut mapper = WindowsInputMapper::default(); let mut pump = WindowsInputPump::default(); + if host_color_query_sent { + // Native records distinguish physical Escape from scan-code-zero host replies. + pump.framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + super::super::query_host_terminal_theme(); + } let mut handoff = WindowsInputHandoff::default(); while !should_quit.load(Ordering::Acquire) { @@ -325,10 +332,12 @@ struct WindowsInputMapper { pending_paste_high_surrogate: Option<u16>, mouse_buttons: WindowsMouseButtons, win32_input: WindowsWin32InputModeFramer, + pending_escape_origin: Option<EscapeOrigin>, } struct WindowsInputPump { framer: crate::raw_input::RawInputFramer, + pending_escape_origin: Option<EscapeOrigin>, paste_from_win32_key_records: bool, pending_physical_escape: Option<(crate::protocol::ClientInputEvent, bool)>, default_mouse_candidate: DefaultMouseCandidate, @@ -346,6 +355,7 @@ impl Default for WindowsInputPump { fn default() -> Self { Self { framer: crate::raw_input::RawInputFramer::for_host_input(), + pending_escape_origin: None, paste_from_win32_key_records: false, pending_physical_escape: None, default_mouse_candidate: DefaultMouseCandidate::default(), @@ -357,6 +367,7 @@ impl Default for WindowsInputPump { #[derive(Clone, Debug, PartialEq, Eq)] enum PlatformInputItem { Bytes(Vec<u8>), + Escape(EscapeOrigin), Semantic(crate::protocol::ClientInputEvent), PasteAwareBytes { paste_bytes: Vec<u8>, @@ -372,6 +383,12 @@ enum PlatformInputItem { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EscapeOrigin { + HostReply, + Keyboard, +} + #[derive(Default)] struct WindowsWin32InputModeFramer { buffer: Vec<u8>, @@ -386,6 +403,17 @@ enum WindowsWin32InputModeItem { } impl WindowsInputPump { + fn flush_before_semantic(&mut self) -> Vec<crate::raw_input::RawInputEvent> { + // A scan-code-zero reply prefix can outlive an unrelated keyboard record. + if self.pending_escape_origin == Some(EscapeOrigin::HostReply) + && self.framer.holds_host_default_color_escape() + { + return Vec::new(); + } + self.pending_escape_origin = None; + self.framer.flush_interrupted() + } + fn process(&mut self, item: PlatformInputItem) -> Vec<crate::protocol::ClientInputEvent> { let mut events = Vec::new(); if let Some((escape, open_bracket)) = self.pending_physical_escape.take() { @@ -412,7 +440,7 @@ impl WindowsInputPump { } if let Some(escape) = item.physical_escape_press() { if !self.framer.has_pending_bracketed_paste() { - let raw_events = self.framer.flush_interrupted(); + let raw_events = self.flush_before_semantic(); events.extend(self.process_raw_events(raw_events)); self.pending_physical_escape = Some((escape, false)); return events; @@ -421,11 +449,17 @@ impl WindowsInputPump { let mut next = match item { PlatformInputItem::Bytes(bytes) => { + self.pending_escape_origin = None; let raw_events = self.framer.push(&bytes); self.process_raw_events(raw_events) } + PlatformInputItem::Escape(origin) => { + self.pending_escape_origin = Some(origin); + let raw_events = self.framer.push(b"\x1b"); + self.process_raw_events(raw_events) + } PlatformInputItem::Semantic(event) => { - let raw_events = self.framer.flush_interrupted(); + let raw_events = self.flush_before_semantic(); let mut events = self.process_raw_events(raw_events); events.push(event); events @@ -444,6 +478,11 @@ impl WindowsInputPump { self.stage_default_mouse_record(nested_record, &raw_bytes); } let decode_win32_record = self.paste_from_win32_key_records || !pending_paste; + self.pending_escape_origin = if decode_win32_record && !pending_paste { + WindowsInputMapper::raw_escape_origin(nested_record) + } else { + None + }; let raw_events = if decode_win32_record { if pending_paste { self.framer.push(&win32_paste_bytes) @@ -487,7 +526,7 @@ impl WindowsInputPump { let raw_events = if events.is_empty() { self.framer.flush_timeout() } else { - self.framer.flush_interrupted() + self.flush_before_semantic() }; let mut output = self.process_raw_events(raw_events); output.extend(events); @@ -508,7 +547,12 @@ impl WindowsInputPump { events.extend(self.process_raw_events(raw_events)); } } - let raw_events = self.framer.flush_timeout(); + let raw_events = if self.pending_escape_origin == Some(EscapeOrigin::Keyboard) { + self.pending_escape_origin = None; + self.framer.flush_keyboard_escape() + } else { + self.framer.flush_timeout() + }; events.extend(self.process_raw_events(raw_events)); events } @@ -606,6 +650,7 @@ fn remove_matching_key(keys: &mut Vec<WindowsKeyRecord>, record: WindowsKeyRecor impl PlatformInputItem { fn raw_bytes(&self) -> Option<&[u8]> { match self { + Self::Escape(_) => Some(b"\x1b"), Self::Bytes(bytes) | Self::PasteAwareBytes { raw_bytes: bytes, .. @@ -684,10 +729,18 @@ impl WindowsInputMapper { self.win32_input .flush_timeout() .into_iter() - .map(PlatformInputItem::Bytes) + .map(|bytes| self.raw_byte_item(bytes)) .collect() } + fn raw_byte_item(&mut self, bytes: Vec<u8>) -> PlatformInputItem { + let origin = self.pending_escape_origin.take(); + match origin { + Some(origin) if bytes.as_slice() == b"\x1b" => PlatformInputItem::Escape(origin), + _ => PlatformInputItem::Bytes(bytes), + } + } + fn translate(&mut self, record: WindowsInputRecord) -> Vec<PlatformInputItem> { match record { WindowsInputRecord::Key(key) => self.translate_key(key), @@ -714,7 +767,10 @@ impl WindowsInputMapper { return Vec::new(); } - if self.key_record_is_raw_escape(key) { + if let Some(origin) = Self::raw_escape_origin(key) { + if self.win32_input.buffer.is_empty() { + self.pending_escape_origin = Some(origin); + } return self.translate_win32_input_mode_bytes(&[0x1b]); } @@ -760,13 +816,13 @@ impl WindowsInputMapper { self.with_pending_win32_flush(items) } - fn key_record_is_raw_escape(&self, key: WindowsKeyRecord) -> bool { + fn raw_escape_origin(key: WindowsKeyRecord) -> Option<EscapeOrigin> { let modifiers = windows_key_modifiers(key.control_key_state); if !key.key_down || key.repeat_count.max(1) != 1 || modifiers.contains(crossterm::event::KeyModifiers::ALT) { - return false; + return None; } // Physical Escape carries a scan code. Scan-code-zero Escape can @@ -778,17 +834,22 @@ impl WindowsInputMapper { && key.unicode == 0x1b && modifiers == crossterm::event::KeyModifiers::CONTROL; - bare_escape || ctrl_bracket + if bare_escape && key.virtual_scan_code == 0 { + Some(EscapeOrigin::HostReply) + } else if bare_escape || ctrl_bracket { + Some(EscapeOrigin::Keyboard) + } else { + None + } } fn translate_win32_input_mode_bytes(&mut self, bytes: &[u8]) -> Vec<PlatformInputItem> { let mut items = Vec::new(); for item in self.win32_input.push(bytes) { match item { - WindowsWin32InputModeItem::Bytes(bytes) => { - items.push(PlatformInputItem::Bytes(bytes)) - } + WindowsWin32InputModeItem::Bytes(bytes) => items.push(self.raw_byte_item(bytes)), WindowsWin32InputModeItem::Key { bytes, record } => { + self.pending_escape_origin = None; trace_input_transport("transport=win32-serialized"); let win32_paste_bytes = self.paste_payload_bytes_for_key(record).unwrap_or_default(); @@ -818,7 +879,7 @@ impl WindowsInputMapper { &mut self, record: WindowsKeyRecord, ) -> Option<Vec<u8>> { - if self.key_record_is_raw_escape(record) { + if Self::raw_escape_origin(record).is_some() { return Some(vec![0x1b]); } @@ -1389,6 +1450,217 @@ fn windows_input_trace_enabled() -> bool { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn host_default_color_replies_reach_shell_without_pane_input() { + use crate::protocol::{ + ClientHostColor, ClientHostDefaultColorKind, ClientHostThemeUpdate, ClientMessage, + }; + + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + assert!(translator.translate(key_char('\x1b')).is_empty()); + for _ in 0..3 { + assert!(translator.idle().is_empty()); + } + assert!(matches!( + translator + .translate(key_vk_with_unicode(0x41, 'a', 0)) + .as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + .. + }] + )); + + let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\\x1b]11;rgb:1111/2222/3333\x1b\\" + .chars() + .map(key_char) + .flat_map(|record| translator.translate(record)) + .collect::<Vec<_>>(); + let mut shell = crate::client::shell::ClientShellState::new( + crate::client::shell::ClientShellConfig::from_config(&crate::config::Config::default()), + ); + let outcome = shell.handle_client_events(&events); + assert_eq!( + outcome.requests, + vec![ + ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::DefaultColor { + kind: ClientHostDefaultColorKind::Foreground, + color: ClientHostColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }, + }, + }, + ClientMessage::ClientShellHostTheme { + update: ClientHostThemeUpdate::DefaultColor { + kind: ClientHostDefaultColorKind::Background, + color: ClientHostColor { + r: 0x11, + g: 0x22, + b: 0x33, + }, + }, + }, + ] + ); + assert!(outcome.actions.is_empty()); + } + + #[cfg(windows)] + #[test] + fn ctrl_bracket_precedes_next_key_while_host_color_reply_is_pending() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + assert!(translator + .translate(key_vk_with_utf16_mods(0xdb, 0x1b, 0x0008)) + .is_empty()); + let events = translator.translate(key_vk_with_unicode(0x0d, '\r', 0)); + assert!(matches!( + events.as_slice(), + [ + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + .. + }, + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + .. + } + ] + )); + } + + #[cfg(windows)] + #[test] + fn ctrl_bracket_idle_does_not_cancel_later_host_color_reply() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + assert!(translator + .translate(key_vk_with_utf16_mods(0xdb, 0x1b, 0x0008)) + .is_empty()); + assert!(matches!( + translator.idle().as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + .. + }] + )); + assert!(translator.translate(key_char('\x1b')).is_empty()); + assert!(translator.idle().is_empty()); + let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\" + .chars() + .map(key_char) + .flat_map(|record| translator.translate(record)) + .collect::<Vec<_>>(); + assert!(matches!( + events.as_slice(), + [crate::protocol::ClientInputEvent::HostDefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Foreground, + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn serialized_host_reply_escape_survives_intervening_key() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); + for record in win32_input_mode_encoded_raw_bytes(b"\x1b") { + assert!(translator.translate(record).is_empty()); + } + let key_events = translator.translate(key_vk_with_unicode(0x0d, '\r', 0)); + assert!(matches!( + key_events.as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + .. + }] + )); + let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\" + .chars() + .map(key_char) + .flat_map(|record| translator.translate(record)) + .collect::<Vec<_>>(); + assert!(matches!( + events.as_slice(), + [crate::protocol::ClientInputEvent::HostDefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Foreground, + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn expired_host_color_reply_tail_does_not_swallow_semantic_key() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::ZERO); + for record in "\x1b]11;rgb:1111/".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + assert!(matches!( + translator + .translate(key_vk_with_unicode(0x0d, '\r', 0)) + .as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + .. + }] + )); + for record in "2222/3333\x1b\\".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(matches!( + translator + .translate(key_vk_with_unicode(0x41, 'a', 0)) + .as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn unanswered_host_color_query_stops_holding_escape() { + let mut translator = WindowsInputTranslator::default(); + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_millis(1)); + std::thread::sleep(std::time::Duration::from_millis(10)); + assert!(translator.translate(key_char('\x1b')).is_empty()); + assert!(matches!( + translator.idle().as_slice(), + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + .. + }] + )); + } + fn key_char(ch: char) -> WindowsInputRecord { WindowsInputRecord::Key(WindowsKeyRecord { key_down: true, @@ -2083,6 +2355,11 @@ mod tests { }, ] { let mut translator = WindowsInputTranslator::default(); + #[cfg(windows)] + translator + .pump + .framer + .host_default_color_query_sent(std::time::Duration::from_secs(1)); assert!(translator .translate(WindowsInputRecord::Key(record)) .is_empty()); diff --git a/src/client/mod.rs b/src/client/mod.rs index b78517ac..d55e4c48 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -70,7 +70,7 @@ use terminal_geometry::{ }; use terminal_geometry::{ host_cell_size_query_required, initial_terminal_geometry, query_host_cell_size, - query_host_terminal_theme, resize_poll_loop, should_query_host_terminal_theme, + query_host_terminal_theme, resize_poll_loop, }; #[cfg(unix)] use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size}; @@ -517,8 +517,7 @@ async fn run_client_loop( let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); // Spawn the stdin reader thread. - let will_query_host_terminal_theme = - state.attach_escape.is_none() && should_query_host_terminal_theme(); + let will_query_host_terminal_theme = state.attach_escape.is_none(); let host_theme_query_pending = Arc::new(AtomicU32::new(0)); let stdin_host_theme_query_pending = host_theme_query_pending.clone(); // Terminals behind ConPTY report no pixel size through the ioctl, so ask the @@ -555,9 +554,9 @@ async fn run_client_loop( ); }); + #[cfg(unix)] if will_query_host_terminal_theme { query_host_terminal_theme(); - #[cfg(not(windows))] if state.shell.is_some() { query_host_terminal_appearance(); } @@ -1191,9 +1190,9 @@ async fn run_client_loop( cell_height_px, pixel_geometry_exact, ) => { - // Palette updates through OSC do not necessarily produce a color-scheme - // notification. Re-query on redraw, including SIGWINCH without a resize, - // so desktop theme switchers can refresh the existing panes in place. + // On Unix, palette changes may lack a color-scheme notification. + // Re-query on redraw, including SIGWINCH without a resize. + #[cfg(unix)] if will_query_host_terminal_theme { host_theme_query_pending.fetch_add(1, Ordering::AcqRel); query_host_terminal_theme(); diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs index d0eafdf6..5e1da58c 100644 --- a/src/client/shell_runtime.rs +++ b/src/client/shell_runtime.rs @@ -751,6 +751,10 @@ pub(super) fn finish_client_shell_input( } continue; } + if endpoints.active_surface_available() { + write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?; + } + continue; } // Host focus belongs to a pending target even when the source has gone offline or has // already had its surface revoked. Route it before the ordinary source-online gate. @@ -791,3 +795,109 @@ pub(super) fn finish_client_shell_input( } Ok(false) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_color_reaches_server_before_first_snapshot() { + #[derive(Clone)] + struct Capture(std::sync::Arc<std::sync::Mutex<Vec<ClientMessage>>>); + + impl endpoint::EndpointTransport for Capture { + fn send(&mut self, message: &ClientMessage) -> std::io::Result<()> { + self.0.lock().unwrap().push(message.clone()); + Ok(()) + } + } + + let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut endpoints = endpoint::EndpointRegistry::new( + Capture(sent.clone()), + 1, + endpoint::EndpointNegotiation::new(Vec::new(), Vec::new()), + ); + let mut state = ClientState::test_new(); + assert!(!state + .shell + .as_ref() + .unwrap() + .endpoint_is_online(&endpoint::ClientEndpointId::Local)); + let outcome = state.shell.as_mut().unwrap().handle_raw_events(vec![ + crate::raw_input::RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Background, + color: crate::terminal_theme::RgbColor { + r: 0x11, + g: 0x22, + b: 0x33, + }, + }, + ]); + let mut pending_activation = None; + let mut endpoint_commands = endpoint_commands::EndpointCommands::default(); + let mut prefix_input_source = crate::platform::RealPrefixInputSource::default(); + let mut scheduled_activation = None; + finish_client_shell_input( + &mut state, + outcome, + None, + &mut endpoints, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &mut scheduled_activation, + ) + .unwrap(); + + assert_eq!( + *sent.lock().unwrap(), + vec![ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::DefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Background, + color: crate::protocol::ClientHostColor { + r: 0x11, + g: 0x22, + b: 0x33, + }, + }, + }] + ); + + let local = endpoint::ClientEndpointId::Local; + assert!(endpoints.set_surface_active(&local, false)); + let outcome = state.shell.as_mut().unwrap().handle_raw_events(vec![ + crate::raw_input::RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Foreground, + color: crate::terminal_theme::RgbColor { r: 4, g: 5, b: 6 }, + }, + ]); + finish_client_shell_input( + &mut state, + outcome, + None, + &mut endpoints, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &mut scheduled_activation, + ) + .unwrap(); + assert_eq!(sent.lock().unwrap().len(), 1); + + assert!(endpoints.set_surface_active(&local, true)); + state.replay_host_theme(&mut endpoints, &local); + let messages = sent.lock().unwrap(); + assert_eq!(messages.len(), 3); + assert_eq!(messages[1], messages[0]); + assert_eq!( + messages[2], + ClientMessage::ClientShellHostTheme { + update: crate::protocol::ClientHostThemeUpdate::DefaultColor { + kind: crate::protocol::ClientHostDefaultColorKind::Foreground, + color: crate::protocol::ClientHostColor { r: 4, g: 5, b: 6 }, + }, + } + ); + } +} diff --git a/src/client/terminal_geometry.rs b/src/client/terminal_geometry.rs index f15141bb..02d6d05a 100644 --- a/src/client/terminal_geometry.rs +++ b/src/client/terminal_geometry.rs @@ -192,10 +192,6 @@ pub(super) fn query_host_terminal_theme() { let _ = write_host_terminal_theme_query(io::stdout()); } -pub(super) fn should_query_host_terminal_theme() -> bool { - !cfg!(windows) -} - pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { let query = crate::terminal_theme::host_terminal_theme_query_sequence( crate::platform::should_query_host_terminal_palette(), diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 9c84985a..249e29ad 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -20,7 +20,6 @@ use crossterm::execute; use crossterm::terminal::{DisableLineWrap, EnableLineWrap}; use super::frame_output::clear_received_kitty_graphics; -use super::terminal_geometry::should_query_host_terminal_theme; // --------------------------------------------------------------------------- // Terminal setup / restore @@ -86,7 +85,7 @@ pub(super) fn setup_terminal_with_capabilities( } (active, buffered_input) } else { - if should_query_host_terminal_theme() { + if should_enable_host_color_scheme_reports(true) { write_host_color_scheme_report_mode(&mut io::stdout(), false)?; } set_mouse_capture(mouse_capture, false)?; @@ -128,7 +127,7 @@ pub(super) fn setup_terminal_with_capabilities( } pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool { - enable_client_protocols && should_query_host_terminal_theme() + enable_client_protocols && !cfg!(windows) } /// Guard that restores the terminal when dropped. diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 2fcac6eb..035d00d3 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -438,11 +438,6 @@ fn color_scheme_change_event_requests_host_theme_query() { )); } -#[test] -fn host_terminal_theme_query_is_disabled_on_windows() { - assert_eq!(should_query_host_terminal_theme(), !cfg!(windows)); -} - #[test] fn write_host_cell_size_query_emits_xtwinops_request() { let mut output = Vec::new(); diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index e7fb2743..6d49592d 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -147,6 +147,10 @@ pub enum ClientInputEvent { }, FocusGained, FocusLost, + HostDefaultColor { + kind: ClientHostDefaultColorKind, + color: ClientHostColor, + }, } /// Pane-domain input after the client has classified and consumed shell actions. @@ -458,6 +462,19 @@ impl ClientInputEvent { Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()), Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained, Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost, + Self::HostDefaultColor { kind, color } => { + crate::raw_input::RawInputEvent::HostDefaultColor { + kind: match kind { + ClientHostDefaultColorKind::Foreground => { + crate::terminal_theme::DefaultColorKind::Foreground + } + ClientHostDefaultColorKind::Background => { + crate::terminal_theme::DefaultColorKind::Background + } + }, + color: (*color).into(), + } + } } } } diff --git a/src/raw_input.rs b/src/raw_input.rs index 2694912c..1af4e894 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -1,4 +1,6 @@ use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +#[cfg(windows)] +use std::time::{Duration, Instant}; /// Parse raw terminal input bytes into a list of `RawInputEvent`s. /// @@ -84,6 +86,11 @@ impl RawInputFramer { } } + #[cfg(windows)] + pub(crate) fn host_default_color_query_sent(&mut self, escape_grace: Duration) { + self.byte_framer.host_default_color_query_sent(escape_grace); + } + pub(crate) fn push(&mut self, data: &[u8]) -> Vec<RawInputEvent> { Self::events_from_chunks(self.byte_framer.push(data)) } @@ -93,6 +100,21 @@ impl RawInputFramer { self.byte_framer.has_pending_input() } + #[cfg(windows)] + pub(crate) fn holds_host_default_color_escape(&self) -> bool { + self.byte_framer.buffer.as_slice() == [ESC] + && self.byte_framer.host_color_replies_awaited > 0 + && self + .byte_framer + .host_default_color_query_deadline + .is_some_and(|deadline| Instant::now() < deadline) + } + + #[cfg(all(test, not(windows)))] + pub(crate) fn holds_host_default_color_escape(&self) -> bool { + false + } + #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.byte_framer.has_pending_bracketed_paste() @@ -107,9 +129,21 @@ impl RawInputFramer { Self::events_from_chunks(self.byte_framer.flush_timeout()) } + #[cfg(any(windows, test))] + pub(crate) fn flush_keyboard_escape(&mut self) -> Vec<RawInputEvent> { + if self.byte_framer.buffer.as_slice() == [ESC] { + self.byte_framer.lone_escape_recently_flushed = true; + Self::events_from_chunks(vec![std::mem::take(&mut self.byte_framer.buffer)]) + } else { + self.flush_timeout() + } + } + /// Semantic input ends mouse recovery, unlike another idle interval. #[cfg(any(windows, test))] pub(crate) fn flush_interrupted(&mut self) -> Vec<RawInputEvent> { + #[cfg(windows)] + self.byte_framer.flush_lone_escape_on_interruption(); let mut chunks = self.byte_framer.flush_timeout(); self.byte_framer.timed_out_mouse_prefix = None; chunks.extend(self.byte_framer.drain_available_chunks()); @@ -144,6 +178,14 @@ pub(crate) struct RawInputByteFramer { timed_out_mouse_prefix: Option<Vec<u8>>, lone_escape_recently_flushed: bool, host_color_replies_awaited: u16, + #[cfg(windows)] + host_default_color_query_issued: bool, + #[cfg(windows)] + host_default_color_query_deadline: Option<Instant>, + #[cfg(windows)] + host_color_tail_deadline: Option<Instant>, + #[cfg(windows)] + host_color_tail_split_st: bool, host_cell_size_replies_awaited: u16, host_appearance_reply_awaited: bool, held_pending_host_reply_esc: bool, @@ -181,12 +223,29 @@ impl RawInputByteFramer { /// at its ESC introducer stitches back together instead of leaking (#549). /// Overlapping queries each add their own replies to the window. pub(crate) fn host_color_query_sent(&mut self) { - self.host_color_replies_awaited = self - .host_color_replies_awaited - .saturating_add(HOST_COLOR_QUERY_REPLIES); + self.host_color_query_sent_with_replies(HOST_COLOR_QUERY_REPLIES); + } + + #[cfg(windows)] + pub(crate) fn host_default_color_query_sent(&mut self, reply_window: Duration) { + self.host_color_query_sent_with_replies(2); + self.host_default_color_query_issued = true; + self.host_default_color_query_deadline = Some(Instant::now() + reply_window); + } + + fn host_color_query_sent_with_replies(&mut self, replies: u16) { + self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_add(replies); self.held_pending_host_reply_esc = false; } + #[cfg(windows)] + fn flush_lone_escape_on_interruption(&mut self) { + if self.buffer.as_slice() == [ESC] { + self.held_pending_host_reply_esc = true; + self.host_default_color_query_deadline = None; + } + } + fn host_appearance_query_sent(&mut self) { self.host_appearance_reply_awaited = true; self.held_pending_host_reply_esc = false; @@ -252,6 +311,16 @@ impl RawInputByteFramer { pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> { let mut chunks = self.drain_available_chunks(); + #[cfg(windows)] + let host_color_query_expired = self + .host_default_color_query_deadline + .is_some_and(|deadline| Instant::now() >= deadline); + #[cfg(windows)] + if host_color_query_expired { + self.host_default_color_query_deadline = None; + self.host_color_replies_awaited = 0; + } + // Idle is not evidence that a mouse report has ended. The continuation // stays bounded and is released if it cannot complete a valid report. if self.timed_out_mouse_prefix.is_some() { @@ -262,6 +331,10 @@ impl RawInputByteFramer { if family == ControlStringFamily::HostReplyCsi { return chunks; } + #[cfg(windows)] + if self.host_color_tail_deadline.is_some() { + return chunks; + } let keep_split_st = self.buffer.last() == Some(&ESC); let keep_discarding = plausible_control_string_tail(family, &self.buffer); self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len()); @@ -323,6 +396,32 @@ impl RawInputByteFramer { return chunks; } + #[cfg(windows)] + if self.host_default_color_query_issued + && is_incomplete_host_default_color_prefix(&self.buffer) + { + if self.host_default_color_query_deadline.is_some() + && self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES + { + return chunks; + } + if host_color_query_expired && self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES { + self.discard_until = Some(ControlStringFamily::Osc); + let split_st = self.buffer.last() == Some(&ESC); + self.discarded_tail_bytes = self.buffer.len() - usize::from(split_st); + self.host_color_tail_deadline = + Some(Instant::now() + HOST_COLOR_TAIL_RECOVERY_GRACE); + self.host_color_tail_split_st = split_st; + self.buffer.clear(); + if split_st { + self.buffer.push(ESC); + } + return chunks; + } + self.buffer.clear(); + return chunks; + } + if starts_with_incomplete_default_color_response(&self.buffer) { tracing::trace!( len = self.buffer.len(), @@ -394,6 +493,13 @@ impl RawInputByteFramer { } if self.buffer.as_slice() == [ESC] { + #[cfg(windows)] + if self.host_color_replies_awaited > 0 + && self.host_default_color_query_deadline.is_some() + { + // Native console reply fragments can arrive across several idle polls. + return chunks; + } if self.awaiting_host_reply() && !self.held_pending_host_reply_esc { self.held_pending_host_reply_esc = true; tracing::trace!("holding lone escape one flush while awaiting host reply"); @@ -401,6 +507,10 @@ impl RawInputByteFramer { } // No continuation arrived; give up the window so Escape is not delayed again. self.host_color_replies_awaited = 0; + #[cfg(windows)] + { + self.host_default_color_query_deadline = None; + } self.host_cell_size_replies_awaited = 0; self.host_appearance_reply_awaited = false; self.held_pending_host_reply_esc = false; @@ -470,6 +580,57 @@ impl RawInputByteFramer { } if let Some(family) = self.discard_until { + #[cfg(windows)] + if let Some(deadline) = self.host_color_tail_deadline { + if self.host_color_tail_split_st + && self.buffer.len() > 1 + && self.buffer[1] != b'\\' + { + self.buffer.drain(..1); + self.end_host_color_tail_recovery(); + continue; + } + let terminator_len = osc_string_terminator(&self.buffer); + if let Some(new_escape) = self + .buffer + .windows(2) + .position(|pair| pair[0] == ESC && pair[1] != b'\\') + { + if terminator_len.is_none_or(|len| new_escape < len) { + self.buffer.drain(..new_escape); + self.end_host_color_tail_recovery(); + continue; + } + } + let body_len = terminator_len + .map(|len| len - if self.buffer[len - 1] == 0x07 { 1 } else { 2 }) + .unwrap_or(self.buffer.len()); + if let Some(invalid) = self.buffer[..body_len] + .iter() + .position(|byte| !plausible_host_color_tail_byte(*byte)) + { + self.buffer.drain(..invalid); + self.end_host_color_tail_recovery(); + continue; + } + if let Some(terminator_len) = terminator_len { + if self.discarded_tail_bytes + terminator_len + <= MAX_DISCARDED_CONTROL_TAIL_BYTES + { + self.buffer.drain(..terminator_len); + self.end_host_color_tail_recovery(); + continue; + } + } + if Instant::now() >= deadline + || self.discarded_tail_bytes + self.buffer.len() + >= MAX_DISCARDED_CONTROL_TAIL_BYTES + { + self.end_host_color_tail_recovery(); + continue; + } + break; + } if family == ControlStringFamily::HostReplyCsi { if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) { @@ -533,9 +694,42 @@ impl RawInputByteFramer { chunks } + + #[cfg(windows)] + fn end_host_color_tail_recovery(&mut self) { + self.discard_until = None; + self.discarded_tail_bytes = 0; + self.host_color_tail_deadline = None; + self.host_color_tail_split_st = false; + } } const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128; +#[cfg(windows)] +const HOST_COLOR_TAIL_RECOVERY_GRACE: Duration = Duration::from_millis(100); + +#[cfg(windows)] +fn plausible_host_color_tail_byte(byte: u8) -> bool { + byte.is_ascii_hexdigit() + || matches!( + byte, + b';' | b':' + | b'/' + | b'#' + | b'?' + | b'.' + | b'_' + | b'-' + | b'+' + | b'r' + | b'g' + | b'b' + | b'R' + | b'G' + | b'B' + | ESC + ) +} fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> bool { match family { @@ -728,6 +922,20 @@ fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool { ) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;")) } +#[cfg(windows)] +fn is_incomplete_host_default_color_prefix(buffer: &[u8]) -> bool { + buffer.starts_with(b"\x1b]") + && matches!( + control_string(buffer), + Some(ControlString::Incomplete { + family: ControlStringFamily::Osc + }) + ) + && [b"\x1b]10;".as_slice(), b"\x1b]11;".as_slice()] + .iter() + .any(|prefix| prefix.starts_with(buffer) || buffer.starts_with(prefix)) +} + fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool { buffer.starts_with(b"\x1b[?") && (GHOSTTY_COLOR_SCHEME_DARK_REPORT.starts_with(buffer) @@ -2537,6 +2745,121 @@ mod tests { assert!(framer.flush_timeout().is_empty()); } + #[cfg(windows)] + #[test] + fn windows_host_color_prefix_respects_query_deadline() { + let mut framer = RawInputFramer::for_host_input(); + framer.host_default_color_query_sent(Duration::from_secs(1)); + assert!(framer.push(b"\x1b]10").is_empty()); + for _ in 0..3 { + assert!(framer.flush_timeout().is_empty()); + } + assert!(framer.push(b";rgb:aaaa/").is_empty()); + for _ in 0..3 { + assert!(framer.flush_timeout().is_empty()); + } + assert!(matches!( + framer.push(b"bbbb/cccc\x1b\\").as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + }] + )); + + let mut expired = RawInputFramer::for_host_input(); + expired.host_default_color_query_sent(Duration::ZERO); + assert!(expired.push(b"\x1b]11;").is_empty()); + assert!(expired.flush_timeout().is_empty()); + assert!(matches!( + expired.push(b"x").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x') + )); + assert!(matches!( + expired.push(b"\x1b]11;rgb:1111/2222/3333\x1b\\").as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Background, + .. + }] + )); + } + + #[cfg(windows)] + #[test] + fn windows_expired_host_color_reply_tail_does_not_reach_pane() { + let mut framer = RawInputFramer::for_host_input(); + framer.host_default_color_query_sent(Duration::ZERO); + assert!(framer.push(b"\x1b]11;rgb:1111/").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"2222/").is_empty()); + for _ in 0..3 { + assert!(framer.flush_timeout().is_empty()); + } + assert!(matches!( + framer.push(b"3333\x1b\\x").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x') + )); + + let mut expired = RawInputFramer::for_host_input(); + expired.host_default_color_query_sent(Duration::ZERO); + assert!(expired.push(b"\x1b]10;rgb:aaaa/").is_empty()); + assert!(expired.flush_timeout().is_empty()); + expired.byte_framer.host_color_tail_deadline = Some(Instant::now()); + assert!(expired.flush_timeout().is_empty()); + assert!(matches!( + expired.push(b"y").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('y') + )); + + let mut raw_keyboard = RawInputFramer::for_host_input(); + raw_keyboard.host_default_color_query_sent(Duration::ZERO); + assert!(raw_keyboard.push(b"\x1b]11;rgb:1111/").is_empty()); + assert!(raw_keyboard.flush_timeout().is_empty()); + assert!(raw_keyboard.push(b"a").is_empty()); + raw_keyboard.byte_framer.host_color_tail_deadline = Some(Instant::now()); + assert!(matches!( + raw_keyboard.flush_timeout().as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('a') + )); + + let mut split_st = RawInputFramer::for_host_input(); + split_st.host_default_color_query_sent(Duration::ZERO); + assert!(split_st.push(b"\x1b]11;rgb:1111/2222/3333\x1b").is_empty()); + assert!(split_st.flush_timeout().is_empty()); + assert!(matches!( + split_st.push(b"\\x").as_slice(), + [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x') + )); + + let mut abandoned_st = RawInputFramer::for_host_input(); + abandoned_st.host_default_color_query_sent(Duration::ZERO); + assert!(abandoned_st + .push(b"\x1b]11;rgb:1111/2222/3333\x1b") + .is_empty()); + assert!(abandoned_st.flush_timeout().is_empty()); + assert!(matches!( + abandoned_st + .push(b"\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\") + .as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + }] + )); + + let mut next_reply = RawInputFramer::for_host_input(); + next_reply.host_default_color_query_sent(Duration::ZERO); + assert!(next_reply.push(b"\x1b]11;rgb:1111/").is_empty()); + assert!(next_reply.flush_timeout().is_empty()); + assert!(next_reply.push(b"\x1b").is_empty()); + assert!(matches!( + next_reply.push(b"]10;rgb:aaaa/bbbb/cccc\x1b\\").as_slice(), + [RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + }] + )); + } + #[test] fn holds_lone_escape_and_stitches_split_host_color_reply() { let mut framer = RawInputByteFramer::default(); From 9dc3a1df2b563df0637264fc0dffcd607c324ac5 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Tue, 29 Sep 2026 03:47:12 +0300 Subject: [PATCH 91/99] fix: summarize old servers and saved machines after herdr update (#4722) * fix: summarize old servers and saved machines after herdr update * fix: include reconnect and per-session restart commands in update summary --- .../next/website/src/content/docs/install.mdx | 2 + .../website/src/content/docs/ja/install.mdx | 2 + .../src/content/docs/zh-cn/install.mdx | 2 + src/update.rs | 237 ++++++++++++++++-- 4 files changed, 225 insertions(+), 18 deletions(-) diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx index 4dac5ede..5ec26d10 100644 --- a/docs/next/website/src/content/docs/install.mdx +++ b/docs/next/website/src/content/docs/install.mdx @@ -141,6 +141,8 @@ Stable is the recommended channel for normal Windows use. Preview receives fixes By default, `herdr update` installs the new binary and leaves endpoint-generation-1 servers and their pane processes running. Start Herdr again to reconnect with the updated client. Client-only changes work immediately. A new server-side action stays unavailable until that server is updated and restarted; the client shows a notice instead of disconnecting. Servers from before endpoint generation 1 need one final stop during update. +When it finishes, `herdr update` lists any running server still on the old version with the command that restarts it, and your saved SSH machines, which run their own Herdr. Run `herdr update` on each machine to update it too. + To opt into experimental live server handoff for supported running sessions, run: ```bash diff --git a/docs/next/website/src/content/docs/ja/install.mdx b/docs/next/website/src/content/docs/ja/install.mdx index 8217d311..587f6df1 100644 --- a/docs/next/website/src/content/docs/ja/install.mdx +++ b/docs/next/website/src/content/docs/ja/install.mdx @@ -141,6 +141,8 @@ Windows の通常利用には安定版を推奨します。プレビューでは デフォルトでは、`herdr update` は新しいバイナリをインストールし、エンドポイント世代 1 のサーバーとペインプロセスを動かしたままにします。再度 Herdr を起動すると、更新済みクライアントで再接続します。クライアントだけの変更はすぐに使えます。新しいサーバー側アクションは、そのサーバーを更新して再起動するまで利用できず、クライアントは切断せずに通知を表示します。エンドポイント世代 1 より前のサーバーだけは、更新時に最後の 1 回の停止が必要です。 +完了時に `herdr update` は、古いバージョンのまま動いているサーバーとそれを再起動するコマンド、そして独自の Herdr を動かしている保存済み SSH マシンを一覧表示します。各マシンでも `herdr update` を実行して更新してください。 + 対応する実行中セッションで実験的なライブサーバーハンドオフにオプトインするには: ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/install.mdx b/docs/next/website/src/content/docs/zh-cn/install.mdx index 8faba506..93885224 100644 --- a/docs/next/website/src/content/docs/zh-cn/install.mdx +++ b/docs/next/website/src/content/docs/zh-cn/install.mdx @@ -141,6 +141,8 @@ Windows 的常规使用建议选择稳定版。预览版能更早获得修复, 默认情况下,`herdr update` 会安装新二进制文件,并让端点第 1 代服务器及其窗格进程继续运行。再次启动 Herdr 即可用更新后的客户端重新连接。纯客户端改动会立即生效。新的服务器端操作会保持不可用,直到对应服务器更新并重启;客户端会显示通知,而不会断开连接。只有早于端点第 1 代的服务器需要在更新时再停止最后一次。 +完成后,`herdr update` 会列出仍在运行旧版本的服务器及重启它们的命令,以及运行各自 Herdr 的已保存 SSH 机器。请在每台机器上也运行 `herdr update` 来更新。 + 要对受支持的运行中会话启用实验性的实时服务器交接,运行: ```bash diff --git a/src/update.rs b/src/update.rs index 958be6e1..854bacdc 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1781,26 +1781,23 @@ fn print_running_session_update_outcomes( return; } + let kept: Vec<KeptServer<'_>> = outcomes + .iter() + .filter(|outcome| outcome.outcome == RunningServerUpdateOutcome::CompatibleServerKept) + .map(|outcome| KeptServer { + label: &outcome.session_label, + version: version_label(outcome.server_version.as_deref()), + stop_command: &outcome.stop_command, + attach_command: outcome.attach_command.as_deref(), + }) + .collect(); + for line in kept_server_notice_lines(&kept, release.label()) { + eprintln!("{line}"); + } + for outcome in outcomes { match outcome.outcome { - RunningServerUpdateOutcome::CompatibleServerKept => { - eprintln!( - "{} {} kept running server v{}.", - outcome.target_noun, - outcome.session_label, - version_label(outcome.server_version.as_deref()) - ); - match &outcome.attach_command { - Some(command) => eprintln!( - "Run `{command}` to reconnect with the updated client. Restart the server later only if you need server-side changes from {}.", - release.label() - ), - None => eprintln!( - "Reconnect with the same socket override to use the updated client. Restart the server later only if you need server-side changes from {}.", - release.label() - ), - } - } + RunningServerUpdateOutcome::CompatibleServerKept => {} RunningServerUpdateOutcome::LiveHandoffComplete => { if let Some(command) = &outcome.attach_command { eprintln!( @@ -2190,6 +2187,7 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> { "Open a new terminal, or reconnect SSH, then start Herdr again to use the updated client. Running servers remain active; restart them later only if you need server-side changes from {}.", release.label() ); + print_saved_machine_update_notice(); } #[cfg(not(windows))] @@ -2224,11 +2222,117 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> { print_outdated_integration_notice_with_updated_binary(&updated_exe); print_running_session_update_outcomes(&server_update_outcomes, &release); + print_saved_machine_update_notice(); } Ok(release.version) } +fn print_saved_machine_update_notice() { + let profiles = match crate::client::endpoint::EndpointCatalog::load_profiles() { + Ok(profiles) => profiles, + Err(error) => { + tracing::debug!(%error, "skipping saved machine update notice"); + return; + } + }; + for line in saved_machine_update_notice_lines(&profiles) { + eprintln!("{line}"); + } +} + +fn saved_machine_update_notice_lines( + profiles: &[crate::client::endpoint::SavedSshEndpoint], +) -> Vec<String> { + let mut targets: Vec<&str> = Vec::new(); + let mut labels: Vec<&str> = Vec::new(); + for profile in profiles.iter().filter(|profile| profile.enabled) { + if !targets.contains(&profile.target.as_str()) { + targets.push(&profile.target); + labels.push(&profile.label); + } + } + if labels.is_empty() { + return Vec::new(); + } + let mut lines = vec![ + String::new(), + "your SSH machines run their own herdr and may be older:".to_string(), + ]; + lines.extend(labels.into_iter().map(|label| format!(" {label}"))); + lines.push( + "run `herdr update` on each one. it will tell you what to restart there.".to_string(), + ); + lines +} + +#[cfg(not(windows))] +struct KeptServer<'a> { + label: &'a str, + version: &'a str, + stop_command: &'a str, + attach_command: Option<&'a str>, +} + +#[cfg(not(windows))] +impl KeptServer<'_> { + fn reconnect(&self) -> String { + match self.attach_command { + Some(attach) => format!("`{attach}`"), + None => "herdr with the same socket override".to_string(), + } + } +} + +#[cfg(not(windows))] +fn kept_server_notice_lines(kept: &[KeptServer<'_>], release_label: &str) -> Vec<String> { + match kept { + [] => Vec::new(), + [server] => vec![ + String::new(), + format!( + "your running server is still v{}. run {} to reconnect; everything keeps working.", + server.version, + server.reconnect() + ), + format!("server fixes in v{release_label} apply only after it restarts."), + format!( + "when your agents are idle, run `{}`, then {}.", + server.stop_command, + server.reconnect() + ), + "this closes running panes and their agents.".to_string(), + ], + servers => { + let width = servers + .iter() + .map(|server| server.label.chars().count()) + .max() + .unwrap_or(0); + let mut lines = vec![ + String::new(), + "your running servers are still older. reconnect as usual; everything keeps working." + .to_string(), + format!("server fixes in v{release_label} apply only after each one restarts:"), + ]; + lines.extend(servers.iter().map(|server| { + format!( + " {:<width$} v{} `{}`, then {}", + server.label, + server.version, + server.stop_command, + server.reconnect() + ) + })); + lines.push( + "restart one when its agents are idle. this closes its panes and their agents." + .to_string(), + ); + lines + } + } +} + fn print_outdated_integration_notice_with_updated_binary(updated_exe: &Path) { let status = Command::new(updated_exe) .args(["integration", "status", "--outdated-only"]) @@ -2407,6 +2511,103 @@ mod tests { use std::sync::{Mutex, OnceLock}; use std::thread; + fn saved_machine( + label: &str, + target: &str, + enabled: bool, + ) -> crate::client::endpoint::SavedSshEndpoint { + let mut profile = + crate::client::endpoint::SavedSshEndpoint::new(label, target, "default").unwrap(); + profile.enabled = enabled; + profile + } + + #[test] + fn saved_machine_notice_lists_enabled_machines_once_per_target() { + let profiles = [ + saved_machine("rohan", "rohan", true), + saved_machine("rohan/agents", "rohan", true), + saved_machine("off", "offbox", false), + saved_machine("workbox", "dev@workbox", true), + ]; + + assert_eq!( + saved_machine_update_notice_lines(&profiles), + [ + "", + "your SSH machines run their own herdr and may be older:", + " rohan", + " workbox", + "run `herdr update` on each one. it will tell you what to restart there.", + ] + ); + } + + #[test] + fn saved_machine_notice_is_empty_without_enabled_machines() { + assert!(saved_machine_update_notice_lines(&[]).is_empty()); + assert!( + saved_machine_update_notice_lines(&[saved_machine("off", "offbox", false)]).is_empty() + ); + } + + #[test] + fn kept_server_notice_names_the_session_restart_commands() { + let default = KeptServer { + label: "default", + version: "0.9.1", + stop_command: "herdr server stop", + attach_command: Some("herdr"), + }; + assert_eq!( + kept_server_notice_lines(&[default], "0.10.0"), + [ + "", + "your running server is still v0.9.1. run `herdr` to reconnect; everything keeps working.", + "server fixes in v0.10.0 apply only after it restarts.", + "when your agents are idle, run `herdr server stop`, then `herdr`.", + "this closes running panes and their agents.", + ] + ); + + let work = KeptServer { + label: "work", + version: "0.9.0", + stop_command: "herdr session stop work", + attach_command: Some("herdr session attach work"), + }; + let default = KeptServer { + label: "default", + version: "0.9.1", + stop_command: "herdr server stop", + attach_command: Some("herdr"), + }; + assert_eq!( + kept_server_notice_lines(&[default, work], "0.10.0"), + [ + "", + "your running servers are still older. reconnect as usual; everything keeps working.", + "server fixes in v0.10.0 apply only after each one restarts:", + " default v0.9.1 `herdr server stop`, then `herdr`", + " work v0.9.0 `herdr session stop work`, then `herdr session attach work`", + "restart one when its agents are idle. this closes its panes and their agents.", + ] + ); + assert!(kept_server_notice_lines(&[], "0.10.0").is_empty()); + + let socket_override = KeptServer { + label: "/tmp/herdr.sock", + version: "0.9.1", + stop_command: "herdr server stop", + attach_command: None, + }; + let lines = kept_server_notice_lines(&[socket_override], "0.10.0"); + assert_eq!( + lines[3], + "when your agents are idle, run `herdr server stop`, then herdr with the same socket override." + ); + } + fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) From 0d49b1c5c6e5dccd82c20d566b6224f3e8257c1f Mon Sep 17 00:00:00 2001 From: Ogulcan Celik <ogulcancelik@gmail.com> Date: Tue, 29 Sep 2026 16:02:25 +0300 Subject: [PATCH 92/99] docs: prepare 0.9.2 release documentation --- CHANGELOG.md | 49 ++++++++ docs/next/CHANGELOG.md | 49 ++++++++ docs/next/README.md | 4 +- docs/next/README.zh-CN.md | 4 +- .../src/content/docs/add-herdr-support.mdx | 7 +- docs/next/website/src/content/docs/agents.mdx | 101 +++++++++-------- .../src/content/docs/configuration.mdx | 2 +- .../website/src/content/docs/integrations.mdx | 13 +-- .../src/content/docs/ja/add-herdr-support.mdx | 7 +- .../src/content/docs/ja/agent-automation.mdx | 2 + .../website/src/content/docs/ja/agents.mdx | 107 +++++++++--------- .../src/content/docs/ja/cli-reference.mdx | 5 +- .../src/content/docs/ja/configuration.mdx | 29 ++++- .../content/docs/ja/connecting-machines.mdx | 22 +++- .../website/src/content/docs/ja/install.mdx | 2 + .../src/content/docs/ja/integrations.mdx | 15 +-- .../src/content/docs/ja/socket-api.mdx | 8 +- .../website/src/content/docs/socket-api.mdx | 2 +- .../content/docs/zh-cn/add-herdr-support.mdx | 7 +- .../content/docs/zh-cn/agent-automation.mdx | 2 + .../website/src/content/docs/zh-cn/agents.mdx | 107 +++++++++--------- .../src/content/docs/zh-cn/cli-reference.mdx | 5 +- .../src/content/docs/zh-cn/configuration.mdx | 29 ++++- .../docs/zh-cn/connecting-machines.mdx | 22 +++- .../src/content/docs/zh-cn/install.mdx | 2 + .../src/content/docs/zh-cn/integrations.mdx | 15 +-- .../src/content/docs/zh-cn/socket-api.mdx | 6 +- 27 files changed, 400 insertions(+), 223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c00199..85abb252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ ## Unreleased +### 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](https://herdr.dev/docs/add-herdr-support/) 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. + ## [0.9.1] - 2026-09-16 ### Added diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 93c00199..85abb252 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,55 @@ ## Unreleased +### 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](https://herdr.dev/docs/add-herdr-support/) 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. + ## [0.9.1] - 2026-09-16 ### Added diff --git a/docs/next/README.md b/docs/next/README.md index 5fe58ac9..55ea2789 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -32,7 +32,7 @@ https://github.com/user-attachments/assets/043ec09f-4bdd-41d5-aee0-8fda6b83e267 - **several machines, one window** — keep local work and saved ssh machines together, with a combined agent list and independent reconnects. [remote machines →](https://herdr.dev/docs/connecting-machines/) - **never hunt for the stuck one** — every pane is marked working, blocked, or idle. when an agent stops and needs an answer, herdr says so. - **agent-native** — agents drive herdr through the cli and socket api: they can spawn panes, prompt each other, and wait until another agent is genuinely blocked. [agent skill →](https://herdr.dev/docs/agent-skill/) -- **runs what you already run** — claude code, codex, cursor, opencode, grok and the rest. herdr doesn't wrap or replace them; it owns their terminals. +- **runs what you already run** — claude code, codex, cursor, opencode, grok and the rest. herdr doesn't wrap or replace them; it owns their terminals. building an agent? [add herdr support →](https://herdr.dev/docs/add-herdr-support/) - **keyboard and mouse, both first-class** — tmux-style prefix keys *and* click, drag, split. pick per moment, not per tool. - **plugins** — extend panes and workflows. [browse the marketplace →](https://herdr.dev/plugins/) - **one rust binary, no electron** — runs in whatever terminal you already use. @@ -57,7 +57,7 @@ run your agents, split panes, walk away. `ctrl+b q` detaches, `herdr` reattaches ## docs -everything lives at [herdr.dev/docs](https://herdr.dev/docs/): [quick start](https://herdr.dev/docs/quick-start/) · [concepts](https://herdr.dev/docs/concepts/) · [supported agents](https://herdr.dev/docs/agents/) · [keyboard](https://herdr.dev/docs/keyboard/) · [configuration](https://herdr.dev/docs/configuration/) · [session state](https://herdr.dev/docs/session-state/) · [connecting machines](https://herdr.dev/docs/connecting-machines/) · [remote](https://herdr.dev/docs/persistence-remote/) · [integrations](https://herdr.dev/docs/integrations/) · [plugins](https://herdr.dev/docs/plugins/) · [socket api](https://herdr.dev/docs/socket-api/) +everything lives at [herdr.dev/docs](https://herdr.dev/docs/): [quick start](https://herdr.dev/docs/quick-start/) · [concepts](https://herdr.dev/docs/concepts/) · [supported agents](https://herdr.dev/docs/agents/) · [keyboard](https://herdr.dev/docs/keyboard/) · [configuration](https://herdr.dev/docs/configuration/) · [session state](https://herdr.dev/docs/session-state/) · [connecting machines](https://herdr.dev/docs/connecting-machines/) · [remote](https://herdr.dev/docs/persistence-remote/) · [integrations](https://herdr.dev/docs/integrations/) · [add herdr support to your agent](https://herdr.dev/docs/add-herdr-support/) · [plugins](https://herdr.dev/docs/plugins/) · [socket api](https://herdr.dev/docs/socket-api/) ## thanks diff --git a/docs/next/README.zh-CN.md b/docs/next/README.zh-CN.md index 43549f12..a07c1d39 100644 --- a/docs/next/README.zh-CN.md +++ b/docs/next/README.zh-CN.md @@ -30,7 +30,7 @@ https://github.com/user-attachments/assets/043ec09f-4bdd-41d5-aee0-8fda6b83e267 - **每个智能体一目了然**——`blocked`、`working`、`done`。真实的终端视图,而不是包装过的转述。 - **分离后工作继续运行**——关闭客户端或 SSH 断线后,后台服务器仍会保持终端运行。服务器或机器重启后,Herdr 会恢复已保存的布局,并可恢复受支持的智能体会话;原有进程不会保留。[会话状态 →](https://herdr.dev/zh-cn/docs/session-state/) - **多台机器,一个窗口**——将本地工作和已保存的 SSH 机器放在一起,使用汇总的智能体列表,各连接独立重连。[远程机器 →](https://herdr.dev/zh-cn/docs/connecting-machines/) -- **智能体也能使用 herdr**——纯 socket api:智能体可以创建窗格、读取输出、互相等待。[智能体技能 →](https://herdr.dev/zh-cn/docs/agent-skill/) +- **智能体也能使用 herdr**——纯 socket api:智能体可以创建窗格、读取输出、互相等待。[智能体技能 →](https://herdr.dev/zh-cn/docs/agent-skill/) 在开发智能体?[为你的智能体添加 herdr 支持 →](https://herdr.dev/zh-cn/docs/add-herdr-support/) - **键盘和鼠标都是一等公民**——tmux 风格的前缀键,*以及*点击、拖动、分割。按当下的场景选择,而不是被工具锁死。 - **插件**——扩展窗格和工作流。[浏览插件市场 →](https://herdr.dev/plugins/) - **单个 rust 二进制,没有 electron**——运行在你已经在用的任何终端里。 @@ -55,7 +55,7 @@ herdr ## 文档 -所有文档都在 [herdr.dev/docs](https://herdr.dev/zh-cn/docs/):[快速开始](https://herdr.dev/zh-cn/docs/quick-start/) · [核心概念](https://herdr.dev/zh-cn/docs/concepts/) · [受支持的智能体](https://herdr.dev/zh-cn/docs/agents/) · [键盘](https://herdr.dev/zh-cn/docs/keyboard/) · [配置](https://herdr.dev/zh-cn/docs/configuration/) · [会话状态](https://herdr.dev/zh-cn/docs/session-state/) · [连接机器](https://herdr.dev/zh-cn/docs/connecting-machines/) · [远程访问](https://herdr.dev/zh-cn/docs/persistence-remote/) · [集成](https://herdr.dev/zh-cn/docs/integrations/) · [插件](https://herdr.dev/zh-cn/docs/plugins/) · [socket api](https://herdr.dev/zh-cn/docs/socket-api/) +所有文档都在 [herdr.dev/docs](https://herdr.dev/zh-cn/docs/):[快速开始](https://herdr.dev/zh-cn/docs/quick-start/) · [核心概念](https://herdr.dev/zh-cn/docs/concepts/) · [受支持的智能体](https://herdr.dev/zh-cn/docs/agents/) · [键盘](https://herdr.dev/zh-cn/docs/keyboard/) · [配置](https://herdr.dev/zh-cn/docs/configuration/) · [会话状态](https://herdr.dev/zh-cn/docs/session-state/) · [连接机器](https://herdr.dev/zh-cn/docs/connecting-machines/) · [远程访问](https://herdr.dev/zh-cn/docs/persistence-remote/) · [集成](https://herdr.dev/zh-cn/docs/integrations/) · [为智能体添加 herdr 支持](https://herdr.dev/zh-cn/docs/add-herdr-support/) · [插件](https://herdr.dev/zh-cn/docs/plugins/) · [socket api](https://herdr.dev/zh-cn/docs/socket-api/) ## 致谢 diff --git a/docs/next/website/src/content/docs/add-herdr-support.mdx b/docs/next/website/src/content/docs/add-herdr-support.mdx index 0ed689a3..b3e46286 100644 --- a/docs/next/website/src/content/docs/add-herdr-support.mdx +++ b/docs/next/website/src/content/docs/add-herdr-support.mdx @@ -67,10 +67,11 @@ You can attach the command to any state report, or send it with `pane report-age After a Herdr server restart, Herdr opens the pane in the same directory and runs that command there. The command must follow these rules: - The first word is a plain command name on the user's `PATH`, such as `my-agent`, not a path. -- No argument contains an apostrophe. +- No argument contains an apostrophe or a control character. +- At most 64 arguments and 8 KiB in total. - Your source must hold the pane first, so send a state report with `report-agent` before or along with the command. Otherwise Herdr answers with `resume_not_accepted`. -Herdr rejects a command that breaks the first two rules with `invalid_resume_argv` and does not apply that report. Users can turn off resume with `[session] resume_agents_on_restore = false`. +Herdr rejects a command that breaks the first three rules with `invalid_resume_argv` and does not apply that report. Users can turn off resume with `[session] resume_agents_on_restore = false`. ## Release on exit @@ -89,7 +90,7 @@ If your agent exits without releasing, Herdr notices once the pane is back at it - Don't let Herdr slow your agent down. Send reports in the background or with a short timeout, and ignore failures. - Send only the latest state. If several changes queue up while a report is in flight, drop the older ones. -- Older Herdr versions ignore the resume command, so everything else keeps working there. +- Resume commands need Herdr 0.10.0 or later. Older versions ignore them, so state reports and release keep working there. ## Use the socket directly diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index 5c4351a5..f843196a 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -1,6 +1,6 @@ --- title: Agents -description: See what Herdr can detect, how agent state works, and how integrations improve it. +description: See which coding agents work with Herdr, either supported by Herdr or supporting Herdr themselves, and how agent state works. --- Herdr is built for running more than one coding agent at a time. Each agent stays in a real terminal pane with its shell, logs, prompts, and running processes intact. Herdr tracks which panes contain agents, rolls their state up to tabs and workspaces, and lets you jump straight to the pane that needs attention instead of polling every terminal by hand. @@ -9,46 +9,58 @@ To coordinate agents from scripts or from another agent, see [Agent automation]( ## Supported agents -Automatic detection works out of the box for common coding agents. The table shows which signal determines `idle`, `working`, and `blocked` for each one. +Agents get Herdr support in one of two ways: Herdr supports them, or they support Herdr themselves. Either way you see each agent's `idle`, `working`, and `blocked` state, get notified when it finishes or needs you, and can wait on it from scripts. -| Agent | State authority | Integration role | +Other agents still run normally in Herdr panes. They just show up as plain terminals. + +### Supported by Herdr + +Herdr recognizes these agents and reads their state from what they draw on screen. To get the same session back after a Herdr server restart, install the agent's integration with `herdr integration install <name>`. See [Integrations](/docs/integrations/) for what each one installs. + +| Agent | Integration | Notes | | --- | --- | --- | -| Pi | lifecycle hooks when installed; otherwise screen manifest | state and session | -| OMP | lifecycle hooks when installed | state and session | -| GitHub Copilot CLI | screen manifest | session | -| Devin CLI | screen manifest | session | -| Kimi Code CLI | lifecycle hooks when installed; otherwise screen manifest | state and session | -| Hermes Agent | screen manifest | session | -| Qoder CLI | screen manifest | session | -| Qwen Code | screen manifest | session | -| Letta Code | screen manifest | session | -| Droid | screen manifest | session | -| OpenCode | lifecycle plugin when installed; otherwise screen manifest | state and session | -| Kilo Code CLI | lifecycle plugin when installed; otherwise screen manifest | state and session | -| MastraCode | lifecycle hooks when installed | state and session | -| Claude Code | screen manifest | session | -| Codex | screen manifest | session | -| Cursor Agent CLI | screen manifest | session | -| Amp | screen manifest | none | -| Grok CLI | screen manifest | session | -| Antigravity CLI | screen manifest | session | -| Kiro CLI | screen manifest | none | -| Maki | screen manifest | none | -| Muse | screen manifest | none | - -Detected but less thoroughly tested: Gemini CLI and Cline. Unsupported agents still run normally as terminal processes. They just may not get rich state unless you add an integration or report state over the socket API. - -## Status authority - -Herdr first detects the foreground process in each pane. After that, each pane has one status authority. - -For agents with complete lifecycle hooks, the integration is authoritative when it is installed and actively reporting for the running pane. Herdr uses those hook reports for `idle`, `working`, `blocked`, and session identity. It does not also run screen manifest fallback for that same lifecycle authority. This avoids two competing sources of truth. - -For agents without complete lifecycle hooks, Herdr identifies the foreground process and reads the live bottom-buffer screen snapshot. It evaluates TOML manifests against that snapshot to classify `idle`, `working`, and `blocked`. For agents that emit them, manifests can also match terminal title and progress (OSC) sequences as detection evidence; when that evidence is absent, screen rules carry detection on their own. - -The screen snapshot comes from the recent bottom of the pane buffer, not the scrolled viewport. If you scroll back in Herdr, detection still follows the live agent UI at the bottom. - -Integrations marked `session` in the table above are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses screen manifest detection. +| Claude Code | `claude` | | +| Codex | `codex` | | +| GitHub Copilot CLI | `copilot` | | +| Cursor Agent CLI | `cursor` | | +| OpenCode | `opencode` | also reports state | +| Pi | `pi` | also reports state | +| OMP | `omp` | state requires the integration | +| Droid | `droid` | | +| Devin CLI | `devin` | | +| Kimi Code CLI | `kimi` | also reports state | +| Kilo Code CLI | `kilo` | also reports state | +| Hermes Agent | `hermes` | | +| Qoder CLI | `qodercli` | | +| Qwen Code | `qwen` | | +| Letta Code | `letta` | CLI install only | +| MastraCode | `mastracode` | state requires the integration | +| Grok CLI | `grok` | | +| Antigravity CLI | `antigravity-cli` | | +| Amp | none | state only | +| Kiro CLI | none | state only | +| Maki | none | state only | +| Gemini CLI | none | state only, less tested | +| Cline | none | state only, less tested | + +### Supported by the agent + +These agents report their own state to Herdr. There is nothing to install; run them in a Herdr pane. + +- [Crush](https://github.com/charmbracelet/crush) +- Command Code +- Muse +- [Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent) + +An agent that also reports its resume command comes back in the same session after a Herdr server restart. + +If you build a coding agent, [Add Herdr support to your agent](/docs/add-herdr-support/) shows how to join this list. It takes a few calls to Herdr's CLI or socket, and no change to Herdr. + +## How status works + +For agents Herdr supports, Herdr finds the agent's process in each pane and reads the live bottom of the pane, not the part you scrolled to. Rules in a detection manifest decide whether that screen means `idle`, `working`, or `blocked`. When an integration also reports state, Herdr uses those reports instead of reading the screen. + +For agents that support Herdr, the agent's own reports decide the state. ## VMs and sandbox wrappers @@ -107,17 +119,6 @@ A blocked agent makes its pane, tab, and workspace look blocked. A working agent This is the main Herdr workflow: start several agents, let them work in parallel, and use the sidebar to see which project needs a decision, which one is still running, and which one is ready to review. -## Direct integrations - -Install the integration for each agent you use to give Herdr hook or plugin reports instead of screen detection alone: - -```bash -herdr integration install claude -herdr integration status -``` - -Each supported agent has its own integration name and behavior. See [Integrations](/docs/integrations/) for the per-agent details and the full install list. If you are building an agent, [Add Herdr support to your agent](/docs/add-herdr-support/) shows how to report lifecycle state and a resume command without adding native support to Herdr. - ## Custom agent labels You can rename an agent target for display: diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 33644e1c..9188dbb5 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -525,7 +525,7 @@ Search the [Config reference](/docs/config-reference/) for scrollback limits, ne Applications and plugins integrate through the standard Kitty graphics protocol emitted in pane terminal output. Herdr renders these images by default in compatible outer terminals. The external socket pane-overlay API has been removed; there is no replacement socket image API. -Popups, menus, and notifications temporarily hide only the image placements they overlap. Uncovered images stay visible, and hidden placements return when uncovered. Images do not dim with dialog backgrounds. +Popups, menus, and notifications crop images around the area they cover; the rest of each image stays visible and the covered part returns when the overlay closes. Images do not dim with dialog backgrounds. Herdr automatically chooses eligible file transports, with no application changes or environment switches. On local Unix Ghostty clients, it can send image data through temporary files instead of encoding it into terminal output. A small file-consumption probe checks support first. Other terminals, SSH-launched clients, and nested multiplexers retain inline output for this last-hop optimization. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 3aff5ae1..691bc306 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -3,7 +3,7 @@ title: Integrations description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, MastraCode, Antigravity CLI, and Grok CLI. --- -Herdr detects supported agents automatically. Install official integrations when you want native agent session restore, direct lifecycle reports, or both. See [Agents](/docs/agents/) for the full status authority model. +Herdr detects the agents it supports automatically. Install an agent's integration so Herdr can resume the same session after a server restart. Some integrations also report the agent's state directly. See [Agents](/docs/agents/) for which agents Herdr supports and which support Herdr themselves. ## Install integrations @@ -57,16 +57,9 @@ Herdr saves integration configs atomically on Linux/macOS and for new files; exi ## How Herdr uses integrations -Herdr uses integrations in two ways: +Every integration tells Herdr which session the agent is in, so Herdr can resume it after a server restart. Turn this off with `[session] resume_agents_on_restore = false`. -| Integration type | Agents | Effect | -| --- | --- | --- | -| Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. | -| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. | - -Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable. - -Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +The Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, and MastraCode integrations also report the agent's state. While they report, Herdr uses them instead of reading the screen. The others leave state to screen detection. Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Qwen Code version `1`, Letta Code version `1`, Cursor Agent CLI version `1`, Grok CLI version `2`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. diff --git a/docs/next/website/src/content/docs/ja/add-herdr-support.mdx b/docs/next/website/src/content/docs/ja/add-herdr-support.mdx index d095bc2d..234b4102 100644 --- a/docs/next/website/src/content/docs/ja/add-herdr-support.mdx +++ b/docs/next/website/src/content/docs/ja/add-herdr-support.mdx @@ -67,10 +67,11 @@ Herdr ペイン内のすべてのプロセスは、次の環境変数を継承 Herdr サーバーの再起動後、Herdr は同じディレクトリでペインを開き、そこでそのコマンドを実行します。コマンドは次のルールに従う必要があります: - 最初の語は、`my-agent` のようにユーザーの `PATH` 上にある単純なコマンド名にします。パスは使えません。 -- どの引数にもアポストロフィを含めません。 +- どの引数にもアポストロフィや制御文字を含めません。 +- 引数は最大 64 個、合計 8 KiB までです。 - 先に source がペインを保持している必要があります。コマンドより前か同時に、`report-agent` で状態を報告してください。そうしないと Herdr は `resume_not_accepted` を返します。 -最初の 2 つのルールに違反するコマンドは、Herdr が `invalid_resume_argv` で拒否し、その報告は適用されません。ユーザーは `[session] resume_agents_on_restore = false` で resume を無効にできます。 +最初の 3 つのルールに違反するコマンドは、Herdr が `invalid_resume_argv` で拒否し、その報告は適用されません。ユーザーは `[session] resume_agents_on_restore = false` で resume を無効にできます。 ## 終了時に解放する @@ -89,7 +90,7 @@ Herdr サーバーの再起動後、Herdr は同じディレクトリでペイ - Herdr のせいでエージェントを遅くしないでください。報告はバックグラウンドか短いタイムアウトで送り、失敗は無視します。 - 最新の状態だけを送ります。報告の送信中に複数の変化がたまったら、古いものは捨てます。 -- 古い Herdr は resume コマンドを無視するので、それ以外の機能はそのまま動きます。 +- resume コマンドには Herdr 0.10.0 以降が必要です。古いバージョンはこれを無視するので、状態の報告と解放はそのまま動きます。 ## ソケットを直接使う diff --git a/docs/next/website/src/content/docs/ja/agent-automation.mdx b/docs/next/website/src/content/docs/ja/agent-automation.mdx index f69d6a3a..0c6be486 100644 --- a/docs/next/website/src/content/docs/ja/agent-automation.mdx +++ b/docs/next/website/src/content/docs/ja/agent-automation.mdx @@ -77,6 +77,8 @@ herdr agent rename w1:p2 reviewer `idle` と `done` はどちらも入力可能な状態です。CLI/API はサーバーの既読状態を使い、未確認の idle を `done` とします。明示的な `pane focus` / `agent focus` は対象を既読にしますが、読み取りでは変わりません。各 TUI クライアントは表示済みの完了を独立して管理するため、Done バッジが CLI やほかのクライアントと異なる場合があります。`blocked` は承認または質問 UI を Herdr が認識した状態です。`unknown` はエージェントが存在するもののライフサイクルを確実に分類できない状態で、成功完了を意味しません。違いが重要なら正確な `--until` を指定してください。 +起動準備、idle な会話の復元、エージェント会話の切り替えは、作業の完了として数えません。起動直後に作業を始めるエージェントは、Herdr が先に idle プロンプトを確認しなくても最初のタスクを完了できます。エージェントのレスポンスには `completion_seq` が含まれる場合があり、誰が確認したかに関係なく、現在の idle 遷移が完了した作業であることを示します。これはその遷移の `state_change_seq` と一致し、起動やセッション変更では設定されません。古いサーバーではこのフィールドが省略される場合があります。古いサーバーに接続したクライアントは、観測した working 状態を使って Done バッジを保護するため、更新の合間に開始して終了したターンを見逃す可能性があります。 + タイムアウトや `agent_prompt_stalled` は、入力が送られていないことを保証しません。二重送信を避けるため、再試行の前にエージェントを読み取ってください。ID とエージェント名はサーバーごとに独立しています。TUI で別のマシンを選んでも、既存ペイン内の CLI コマンドの接続先は変わりません。 `pane wait-output` はエージェントのライフサイクルを解釈しません。選択したターミナルスナップショットをポーリングし、最初にすぐ検索するため、すでに存在する文字列も一致します。デフォルトのソース名は `recent` で、直近 80 行の描画済みターミナル行を折り返し前の出力として扱います。`--lines` でその行数を変更でき、`--regex` は Rust の正規表現構文で 1 行ずつ一致します。 diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index cb71a678..f3bfaa96 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -1,6 +1,6 @@ --- title: エージェント -description: Herdr が何を検出できるか、エージェント状態の仕組み、インテグレーションによる精度向上。 +description: Herdr で使えるコーディングエージェント (Herdr が対応するもの、自ら Herdr に対応するもの) と、エージェント状態の仕組み。 --- Herdr は複数のコーディングエージェントを同時に動かすために作られています。各エージェントは、シェル、ログ、プロンプト、実行中プロセスをそのまま保った実際のターミナルペインの中にいます。Herdr はどのペインにエージェントがいるかを追跡し、その状態をタブとワークスペースに集約し、すべてのターミナルを手作業で見回る代わりに、注意が必要なペインへ直接ジャンプできるようにします。 @@ -9,46 +9,58 @@ Herdr は複数のコーディングエージェントを同時に動かすた ## 対応エージェント -一般的なコーディングエージェントは、追加設定なしで自動検出されます。重要な違いは Herdr がエージェントを見えるかどうかではありません。どのシグナルが `idle`、`working`、`blocked` を決定する権限を持つかです。 +エージェントが Herdr 対応になる方法は 2 つあります。Herdr がそのエージェントに対応するか、エージェント自身が Herdr に対応するかです。どちらでも、各エージェントの `idle`、`working`、`blocked` 状態が表示され、完了時や対応が必要なときに通知され、スクリプトから待機できます。 -| エージェント | 状態の権威 | インテグレーションの役割 | +それ以外のエージェントも Herdr のペインで普通に動きます。単に通常のターミナルとして表示されるだけです。 + +### Herdr が対応するエージェント + +Herdr はこれらのエージェントを認識し、画面の表示から状態を読み取ります。Herdr サーバーの再起動後に同じセッションへ戻るには、`herdr integration install <name>` でエージェントのインテグレーションをインストールしてください。各インテグレーションがインストールする内容は[インテグレーション](/ja/docs/integrations/)を参照してください。 + +| エージェント | インテグレーション | 備考 | | --- | --- | --- | -| Pi | インストール時はライフサイクルフック。それ以外はスクリーンマニフェスト | 状態とセッション | -| OMP | インストール時はライフサイクルフック | 状態とセッション | -| GitHub Copilot CLI | スクリーンマニフェスト | セッション | -| Devin CLI | スクリーンマニフェスト | セッション | -| Kimi Code CLI | インストール時はライフサイクルフック。それ以外はスクリーンマニフェスト | 状態とセッション | -| Hermes Agent | スクリーンマニフェスト | セッション | -| Qoder CLI | スクリーンマニフェスト | セッション | -| Qwen Code | スクリーンマニフェスト | セッション | -| Letta Code | スクリーンマニフェスト | セッション | -| Droid | スクリーンマニフェスト | セッション | -| OpenCode | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | -| Kilo Code CLI | インストール時はライフサイクルプラグイン。それ以外はスクリーンマニフェスト | 状態とセッション | -| MastraCode | インストール時はライフサイクルフック | 状態とセッション | -| Claude Code | スクリーンマニフェスト | セッション | -| Codex | スクリーンマニフェスト | セッション | -| Cursor Agent CLI | スクリーンマニフェスト | セッション | -| Amp | スクリーンマニフェスト | なし | -| Grok CLI | スクリーンマニフェスト | セッション | -| Antigravity CLI | スクリーンマニフェスト | セッション | -| Kiro CLI | スクリーンマニフェスト | なし | -| Maki | スクリーンマニフェスト | なし | -| Muse | スクリーンマニフェスト | なし | - -検出されるもののテストが薄いもの: Gemini CLI と Cline。未対応のエージェントも通常のターミナルプロセスとして問題なく動きます。ただし、インテグレーションを追加するかソケット API で状態を報告しない限り、詳細な状態は得られない可能性があります。 - -## 状態の権威 - -Herdr はまず各ペインのフォアグラウンドプロセスを検出します。その後、各ペインはひとつの状態権威を持ちます。 - -完全なライフサイクルフックを持つエージェントでは、インテグレーションがインストールされ、実行中のペインについて能動的に報告している間は、インテグレーションが権威です。Herdr はそのフック報告を `idle`、`working`、`blocked` とセッション識別に使います。同じライフサイクル権威に対してスクリーンマニフェストのフォールバックを並走させることはしません。これにより、真実の情報源が 2 つ競合する状況を避けます。 - -完全なライフサイクルフックを持たないエージェントでは、Herdr はフォアグラウンドプロセスを識別し、ライブの下部バッファのスクリーンスナップショットを読みます。そのスナップショットに対して TOML マニフェストを評価し、`idle`、`working`、`blocked` を分類します。それらを発するエージェントでは、マニフェストはターミナルタイトルと進捗 (OSC) シーケンスも検出の証拠としてマッチできます。その証拠がない場合は、スクリーンルールが単独で検出を担います。 - -スクリーンスナップショットは、スクロールされたビューポートではなく、ペインバッファの直近の下部から取得されます。Herdr でスクロールバックしても、検出は下部のライブなエージェント UI を追い続けます。 - -上の表で「セッション」と記載されたインテグレーションは、意図的にライフサイクル権威にしていません。これらは復元のためのネイティブセッション識別を提供しますが、フックがライフサイクル全体をカバーしていません。許可承認の結果、Esc による中断、その他の遷移を見逃すことがあります。これらのエージェントでは、Herdr は引き続きスクリーンマニフェスト検出を使います。 +| Claude Code | `claude` | | +| Codex | `codex` | | +| GitHub Copilot CLI | `copilot` | | +| Cursor Agent CLI | `cursor` | | +| OpenCode | `opencode` | 状態も報告 | +| Pi | `pi` | 状態も報告 | +| OMP | `omp` | 状態にはインテグレーションが必要 | +| Droid | `droid` | | +| Devin CLI | `devin` | | +| Kimi Code CLI | `kimi` | 状態も報告 | +| Kilo Code CLI | `kilo` | 状態も報告 | +| Hermes Agent | `hermes` | | +| Qoder CLI | `qodercli` | | +| Qwen Code | `qwen` | | +| Letta Code | `letta` | CLI からのみインストール | +| MastraCode | `mastracode` | 状態にはインテグレーションが必要 | +| Grok CLI | `grok` | | +| Antigravity CLI | `antigravity-cli` | | +| Amp | なし | 状態のみ | +| Kiro CLI | なし | 状態のみ | +| Maki | なし | 状態のみ | +| Gemini CLI | なし | 状態のみ、テストは限定的 | +| Cline | なし | 状態のみ、テストは限定的 | + +### Herdr に対応するエージェント + +これらのエージェントは自分の状態を Herdr に報告します。インストールするものはありません。Herdr のペインで実行するだけです。 + +- [Crush](https://github.com/charmbracelet/crush) +- Command Code +- Muse +- [Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent) + +resume コマンドも報告するエージェントは、Herdr サーバーの再起動後に同じセッションで戻ります。 + +コーディングエージェントを開発している場合は、[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)でこの一覧に加わる方法を確認できます。Herdr の CLI かソケットを数回呼ぶだけで、Herdr 側の変更は不要です。 + +## 状態の仕組み + +Herdr が対応するエージェントでは、Herdr は各ペインでエージェントのプロセスを見つけ、スクロールした位置ではなくペインのライブな下部を読みます。検出マニフェストのルールが、その画面が `idle`、`working`、`blocked` のどれかを判定します。インテグレーションが状態も報告している場合、Herdr は画面を読む代わりにその報告を使います。 + +Herdr に対応するエージェントでは、エージェント自身の報告が状態を決めます。 ## VM とサンドボックスラッパー @@ -58,9 +70,11 @@ Linux と macOS では、ホストから見えるラッパーが実際のエー ## blocked 状態 -スクリーンマニフェスト方式のエージェントでは、blocked の検出は意図的に厳格です。Herdr が `blocked` と判定するのは、ライブの下部バッファスナップショットが既知の承認・質問・許可 UI にマッチしたときだけです。既知のエージェントでどのマニフェストルールにもマッチしない場合、Herdr は `idle` にフォールバックし、explain の出力ではそのフォールバックに `default_known_agent_idle_fallback` というラベルを付けます。 +スクリーンマニフェスト方式のエージェントでは、blocked の検出は意図的に厳格です。Herdr が `blocked` と判定するのは、ライブの下部バッファスナップショットが既知の承認・質問・許可 UI にマッチしたときだけです。Codex 以外の既知エージェントでどのマニフェストルールにもマッチしない場合、Herdr は `idle` にフォールバックし、explain の出力ではそのフォールバックに `default_known_agent_idle_fallback` というラベルを付けます。Codex は、タイトルとコンポーザーがアクティブなターン中と応答後で同じに見える場合があるため、`unknown` にフォールバックします。 -つまり、見慣れない新しいエージェントプロンプトは、Herdr がその画面の形を学習するまで、最初は `blocked` ではなく `idle` と表示されることがあります。こうしたやり取りによって Herdr が入力を送ったり破壊的な操作をしたりすることはありません。影響するのは表示上の状態と wait だけです。 +それら Codex 以外のエージェントでは、見慣れない新しいプロンプトが、Herdr がその画面の形を学習するまで、最初は `blocked` ではなく `idle` と表示されることがあります。誤分類の影響は表示上の状態と wait だけです。これによって Herdr が入力を送ったり破壊的な操作をしたりすることはありません。 + +Codex では、表示中のスピナーや稼働中のアクティビティタイマーから `working` を、表示中の承認プロンプトから `blocked` を判定できます。通常のタイトルやコンポーザー、スピナーがないことだけでは、ターンの終了を判定できません。そのため、Codex は応答後も `unknown` のままになることがあり、`idle` または完了を待つ処理はタイムアウトする場合があります。管理対象の起動では、初期コンポーザーをプロンプト受付可能になったかの判定にのみ使います。この観測によってターン状態は変わりません。 ## 検出マニフェスト @@ -105,17 +119,6 @@ blocked なエージェントは、そのペイン、タブ、ワークスペー これが Herdr の中心的なワークフローです: 複数のエージェントを起動し、並行して働かせ、サイドバーでどのプロジェクトが判断を必要としているか、どれがまだ実行中か、どれがレビュー待ちかを把握します。 -## ダイレクトインテグレーション - -使っている各エージェントのインテグレーションをインストールしてください。スクリーン検出だけに頼らず、フックやプラグインの報告を Herdr に提供します: - -```bash -herdr integration install claude -herdr integration status -``` - -対応エージェントごとに、インテグレーションの名前と挙動は異なります。エージェント別の詳細と完全なインストール一覧は[インテグレーション](/ja/docs/integrations/)を参照してください。エージェントを開発している場合は、[エージェントに Herdr 対応を追加する](/ja/docs/add-herdr-support/)で、Herdr にネイティブサポートを追加せずにライフサイクル状態と resume コマンドを報告する方法を確認できます。 - ## カスタムエージェントラベル 表示用にエージェントターゲットの名前を変えられます: diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 2904b069..446f1374 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -49,6 +49,7 @@ herdr api schema --output herdr-api.schema.json ```bash herdr machine list +herdr machine add workbox herdr machine add workbox --label "Build machine" herdr machine rename <profile-id> --label "New name" herdr machine disable <profile-id> @@ -56,7 +57,7 @@ herdr machine enable <profile-id> herdr machine remove <profile-id> ``` -`machine add` はリモートのデフォルトセッションを使います。名前付きセッションを使う場合だけ `--remote-session <name>` を追加します。スクリプトでは `machine list` に `--json` を追加できます。詳しくは[マシンへの接続](/ja/docs/connecting-machines/)を参照してください。 +対話型ターミナルでは、`machine add` は実行中のセッションを検出し、複数あれば選択できます。実行中のセッションがない場合や Herdr がインストールされていない場合は `default` を使います。インストール済みバイナリへの問い合わせに失敗した場合は、明示的な選択が必要です。検出を省略するには `--remote-session <name>` を追加します。非対話型コマンドでは、このフラグがない限り `default` を使います。スクリプトでは `machine list` に `--json` を追加できます。詳しくは[マシンへの接続](/ja/docs/connecting-machines/)を参照してください。 `machine add` はリモート側の Herdr が必要な機能を備えているか確認し、必要な場合だけ承認を得てインストールまたは更新します。指定したセッションのバックグラウンドサーバーを起動してからプロファイルを保存します。互換性があればリリースバージョンは一致しなくてもかまいません。インストールや再起動の承認が必要な場合は、対話型ターミナルで実行してください。セットアップが失敗またはキャンセルされた場合、プロファイルは保存されません。実行中サーバーの置き換えには明示的な承認が必要で、デフォルトの回答は No です。停止するとペインのプロセスも終了します。`machine add` は実験的なハンドオフを暗黙に有効にしません。 @@ -473,6 +474,8 @@ herdr plugin pane close <pane_id> `plugin pane open` は、プラグインがリンクされ、有効で、現在のプラットフォームと互換であることを要求します。マニフェストで宣言された `[[panes]]` コマンドを Herdr 管理のターミナルペインとして起動します。マニフェストのデフォルトは `overlay` で、アクティブなペインの上に一時的なズームオーバーレイを開きます。分割、新しいタブ、ズームされたペイン、またはタブレイアウトを変更しないセッションモーダルな `popup` として開くこともできます。`--width` と `--height` は、外側のポップアップ寸法をターミナルセル数または `80%` のような割合で指定します。省略した寸法はデフォルトでターミナルの半分になり、小さすぎる値はポップアップの最小サイズに制限されます。ポップアップは Herdr ペインではなく、`HERDR_PANE_ID` を受け取らず、pane API やエージェント API に参加しません。ターミナル以外のネイティブなプラグインペインは今後の対応面です。 +新しいペインでは `TERM_PROGRAM=herdr` として識別され、`TERM_PROGRAM_VERSION` には Herdr のバージョンが設定されます。`TERM=xterm-256color` と `COLORTERM=truecolor` は維持されます。Herdr は、明示的な起動環境の値を適用する前に、継承した外側のターミナルのセッションマーカー(iTerm2、WezTerm、Kitty、Windows Terminal、tmux、screen、Zellij)、iTerm2 の `LC_TERMINAL` 識別子、既知の Claude Code、Codex、OMP セッションマーカーを削除します。これにより、新しいペインがサーバーを最初に起動したターミナルやエージェントのセッションを名乗ることを防ぎます。API キーやディスプレイ設定を含む通常の環境変数は、アタッチ中クライアントの環境に置き換えられず、引き続き継承されます。SSH エージェント転送には独自の再接続処理があります。このクリーンアップによって既存ペインのプロセスは変更されません。 + `--env KEY=VALUE` はプロセスを起動するコマンドで繰り返し指定できます。新しく起動されるプロセスにのみ適用されます。`HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ENV`、`HERDR_WORKSPACE_ID`、`HERDR_TAB_ID`、`HERDR_PANE_ID`、`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、`HERDR_PLUGIN_CONFIG_DIR`、`HERDR_PLUGIN_STATE_DIR`、`HERDR_PLUGIN_ENTRYPOINT_ID`、`HERDR_PLUGIN_CONTEXT_JSON` のような Herdr 管理の変数は、呼び出し側が与えた環境変数と衝突した場合も権威を保ちます。 ## 読み取りソース diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index 65b683f8..0759b432 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -73,7 +73,7 @@ headless_rows = 50 default_shell = "nu" ``` -未設定または空の場合、Herdr は `$SHELL`、次に Unix では `/bin/sh`、Windows では PowerShell を使います。これは実行ファイル名またはパスであり、シェルのコマンドラインではありません。既存のペインは再作成されるまで現在のシェルを保ちます。カスタムコマンドキーバインドの文字列は、Unix ではペインコマンドに `/bin/sh -c`、デタッチコマンドに `/bin/sh -lc` を通して実行され、Windows では `cmd.exe /d /c` を通して実行されます。 +未設定または空の場合、Herdr は Unix では `$SHELL`、次に `/bin/sh` を使います。Windows では、`PATH` 上で解決できる場合は `pwsh.exe`(PowerShell 7)を使い、それ以外は標準搭載の `powershell.exe`(Windows PowerShell 5.1)を使います。これは実行ファイル名またはパスであり、シェルのコマンドラインではありません。既存のペインは再作成されるまで現在のシェルを保ちます。カスタムコマンドキーバインドの文字列は、Unix ではペインコマンドに `/bin/sh -c`、デタッチコマンドに `/bin/sh -lc` を通して実行され、Windows では `cmd.exe /d /c` を通して実行されます。 新しく作成される対話型ペインのシェルの起動方法を設定します: @@ -142,6 +142,15 @@ navigate_pane_down = "ctrl+j" split_horizontal = "prefix+minus" ``` +`keys.prefix` は、複数のキーからプレフィックスモードに入りたい場合には配列も受け付けます。たとえば、macOS とリモート Linux マシンで 1 つの設定を共有できます: + +```toml +[keys] +prefix = ["ctrl+space", "ctrl+s"] +``` + +設定したどのプレフィックスでも同じプレフィックスモードに入り、すべての `prefix+...` アクションを開始できます。最初の項目がステータスバーに表示されるプライマリプレフィックスとなり、`prefix+?` のアプリ内ヘルプパネルにはすべてのプレフィックスが表示されます。 + デフォルトのキーマップはプレフィックス優先なので、Herdr がシェル、エディタ、tmux、ターミナルアプリから入力を奪うことはありません。[設定リファレンス](/docs/config-reference/)で `keys.` を検索すると、すべてのアクションとデフォルトのバインドを確認できます。`prefix+?` で開くアプリ内ヘルプパネルには、現在有効なバインドが表示されます。 1 つのアクションに複数のショートカットが必要な場合、バインドを配列にすることもできます: @@ -244,6 +253,8 @@ name = "catppuccin" すべての組み込みテーマは、[設定リファレンス](/docs/config-reference/)で `theme.name` を検索してください。Herdr の UI 色をホストターミナルの ANSI パレットに従わせたいときは `terminal` を使ってください。 +Unix では、リサイズまたは `SIGWINCH` 後の再描画時にも、Herdr はホストターミナルの色を再読み込みします。テーマ切り替えツールがライト/ダーク通知を送らずに OSC でターミナル色を変更する場合は、新しいパレットを適用した後に `kill -WINCH <client-pid>` を送ってください。サーバーではなく、アタッチ中の Herdr クライアントを対象にします。既存のペインは動作し続けます。直接の `herdr terminal attach` セッションでは、パレットの問い合わせはアタッチ先アプリケーションに委ねられます。 + ホストターミナルがライト/ダークの外観変更を報告したときに、Herdr が自身の UI テーマを切り替えるようにするには、テーマの自動切り替えを有効にします: ```toml @@ -508,9 +519,19 @@ claude = "on" ## Kitty graphics -Herdr は、対応する外側のターミナルでペイン画像をデフォルトで描画します。ポップアップ、メニュー、通知は、重なる画像の配置だけを一時的に隠します。重ならない画像は表示されたままで、隠れた配置は覆いがなくなると戻ります。画像はダイアログの背景と一緒に暗くなりません。 +アプリケーションとプラグインは、ペインのターミナル出力に送られる標準の Kitty graphics プロトコルを通じて統合します。Herdr は、対応する外側のターミナルでこれらの画像をデフォルトで描画します。外部ソケットのペインオーバーレイ API は削除され、代替となるソケット画像 API はありません。 + +ポップアップ、メニュー、通知は、覆う領域に合わせて画像を切り抜きます。各画像の残りは表示されたままで、覆われた部分はオーバーレイが閉じると戻ります。画像はダイアログの背景と一緒に暗くなりません。 + +Herdr は、アプリケーションの変更や環境変数による切り替えなしで、利用可能なファイル転送方式を自動的に選びます。ローカル Unix の Ghostty クライアントでは、画像データをターミナル出力へエンコードする代わりに、一時ファイル経由で送信できます。最初に小さなファイル読み取りプローブで対応状況を確認します。その他のターミナル、SSH で起動したクライアント、ネストしたマルチプレクサーでは、この最終区間の最適化にインライン出力を使い続けます。 + +対象となるローカルクライアントでは、ネイティブ RGBA 画像はピクセルバイトではなく、Herdr が所有するスナップショットへのパスとしてサーバーからクライアントへ送られます。未対応のクライアントや失敗した転送はインライン配信にフォールバックします。リモートサーバーのパスがローカルターミナルへ転送されることはありません。画像の配置、クリッピング、可視性は Herdr が管理します。 + +Linux では、ファイル全体を使う非圧縮 RGBA アップロードについて、Herdr がピクセルを読み取る前にコピーオンライトのスナップショットを作成できます。これには、Herdr 専用の `/var/tmp` ストアへのクローンをファイルシステムがサポートしている必要があります。未対応のファイルシステム、一時ファイルのアップロード、共有メモリ、その他の形式では通常のローダーを使います。スナップショットは 1 個あたり 16 MiB、合計 64 MiB に制限されます。アニメーションと未対応クライアントでは、必要な時点でピクセルを読み取ります。Herdr は画像を受理した後、変更可能な生成元パスに依存しません。 + +PNG アップロードでは完全なデコードと検証を引き続き行います。破損画像の検証を外側のターミナルへ先送りすることはありません。 -グラフィックス描画とペイングラフィックス API を無効にするには、次のように設定します: +Kitty graphics の描画を無効にするには、次のように設定します: ```toml [terminal] @@ -519,7 +540,7 @@ kitty_graphics = false 既存の設定との互換性のため、従来の `experimental.kitty_graphics` 設定も引き続き使用できます。両方が設定されている場合は、`terminal.kitty_graphics` が優先されます。 -この設定を変更した場合は、対象の Herdr サーバーを再起動するか、クライアントを再アタッチする必要があります。リモートセッションでは、サーバー側の設定がペイングラフィックスの解析と API の可用性を制御し、ローカルクライアント側の設定が外側のターミナルへの出力を制御します。 +この設定を変更した場合は、対象の Herdr サーバーを再起動するか、クライアントを再アタッチする必要があります。リモートセッションでは、サーバー側の設定が Kitty graphics の解析を制御し、ローカルクライアント側の設定が外側のターミナルへの出力を制御します。 ## Agent セッション復元 diff --git a/docs/next/website/src/content/docs/ja/connecting-machines.mdx b/docs/next/website/src/content/docs/ja/connecting-machines.mdx index ef8bb5bc..9a9e0dc7 100644 --- a/docs/next/website/src/content/docs/ja/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/ja/connecting-machines.mdx @@ -7,6 +7,8 @@ description: Local と保存済み SSH マシンを 1 つの Herdr ウィンド 各マシンは独自の Herdr サーバー、セッション、プロセスを保持します。1 台への接続が切れても、ほかの接続は切れません。 +Herdr は、帯域が限られた接続で画面更新の通信量を減らすため、SSH 圧縮を要求します。互換性のある古い Herdr サーバーでも動作し、キー入力などの入力の届け方は変わりません。Herdr の `-C` オプションは SSH 設定の `Compression no` を上書きします。SSH が外部の ControlMaster を通じて既存接続を再利用する場合、その接続では元の圧縮設定が維持されます。 + ### SSH 認証の復旧 Linux と macOS で `remote.manage_ssh_config=true` の場合、マシン追加、サイドバー、API 操作は承認済み OpenSSH 接続を共有します。`herdr machine status [<label-or-id>] [--json]` は認証を求めず、現在の接続状態を確認します。**reachable** はリモート Herdr が利用可能という意味で、各 TUI の接続状態ではありません。ターミナルで `herdr machine reconnect <label-or-id>` を実行すると、SSH 認証後に保存済みマシンを検証します。インストールや更新は行いません。開いているクライアントは 30 秒以内に再試行し、再起動は不要です。**`! auth`**、**`! error`**、失敗後の再接続ステータスをクリックすると、最新のエラーと CLI コマンドが通知に表示されます。認証ポップアップは開きません。マシン行の展開・折りたたみは変更されません。 @@ -30,17 +32,23 @@ ssh workbox インストールや置き換えの前に確認できるよう、対話型ターミナルで実行します: ```bash -herdr machine add workbox --label "Build machine" +herdr machine add workbox ``` -リモートのデフォルトセッションを使います。1 つのプロファイルは 1 つのリモートセッションを対象とし、そのホストの全セッションをまとめるものではありません。 +対話型ターミナルでは、Herdr は実行中のセッションを検出します。1 つだけならそれを選択し、複数ある場合は Up/Down と Enter で選べます。Esc または Ctrl+C でキャンセルします。問い合わせに成功しても実行中のセッションがなければ、セットアップは `default` を使います。Herdr がインストールされていない場合は、`default` 用にインストールするか確認します。インストール済みバイナリがセッションを報告できない場合はエラーを表示します。セッションを明示的に選ぶには `--remote-session <name>` を指定してください。 + +デフォルトセッションはサイドバーに `workbox` と表示されます。これは `user@` プレフィックスを除いた SSH ホストです。別の名前にするには `--label "Build machine"` を追加します。1 つのプロファイルは 1 つのリモートセッションを対象とし、そのホストの全セッションをまとめるものではありません。非対話型コマンドでは、`--remote-session` がない限り `default` を使います。 -名前付きセッションを使いたい場合だけ、任意の `--remote-session` を追加します: +検出を省略して特定のセッションを使うには、`--remote-session` を追加します: ```bash -herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add workbox --remote-session agents ``` +`--label` がない場合、このマシンは `workbox/agents` と表示されます。デフォルト名がすでに使われている場合、`machine add` は `--label` の指定を求めます。 + +Windows サーバーでは、SSH ユーザーが所有する Herdr プロセスで使われている実行ファイルも確認します。再利用前に機能を検証するため、SSH の `PATH` が古いインストールを指していても、互換性のある実行中ビルドを利用できます。 + Herdr はインストール済みバイナリと実行中サーバーをそれぞれ確認し、対象のバックグラウンドサーバーを起動してからプロファイルを保存します。互換性があればクライアントとサーバーのバージョンは一致しなくても構いません。未インストールや非互換の場合は、承認付きのセットアップを行います。実行中サーバーの置き換えが必要なら、サーバーとペインのプロセスを停止する前に確認し、その後に互換サーバーを起動します。デフォルトの回答は No です。インストールと置き換えの両方が必要な場合は、1 回の確認で承認します。`machine add` は実験的なライブハンドオフを使いません。セットアップをキャンセルした場合や失敗した場合、プロファイルは保存されません。 `herdr` で UI を開きます。ローカルクライアントがすでに開いていれば、追加や有効化したマシンは通常 1 秒以内に表示され、選択を変えずにバックグラウンドで接続します。マシン切り替え中の変更は、切り替えの完了後に反映します。セットアップ終了後もリモートサーバーは動き続けます。 @@ -95,6 +103,10 @@ herdr --remote workbox 認証に失敗したら、まず通常の SSH を確認します。パスフレーズ付きの鍵は、非対話型のバックグラウンド接続を始める前に `ssh-add` で読み込んでください。 +リモートペイン内で Git 認証や SSH 署名を使うには、信頼できるホストについて SSH 設定で `ForwardAgent yes` を有効にしてください。Herdr が転送を有効にすることはありません。セッションがエージェント付きで起動した場合、更新済みの Linux と macOS サーバーはペインに安定したエージェントアドレスを与えるため、`--remote` または保存済みマシンが再接続した後も、既存ペインと新規ペインの両方で転送されたエージェントを利用できます。動作中の継承エージェントや先行接続は、後から接続したクライアントや一時的なセットアップ確認より優先されます。それが消えた場合は、別の稼働中アタッチがエージェントを提供できます。 + +これにはローカルクライアントだけでなく、更新済みのリモートサーバーが必要です。エージェントなしで開始したローカルセッションでは `SSH_AUTH_SOCK` を変更しません。サーバー更新前、またはセッションに初めてエージェントが提供される前に作成されたペインは元の環境を維持するため、安定したアドレスを継承するには一度作り直す必要があります。互換性のある古いサーバーやエージェント転送なしの接続も、通常どおりアタッチできます。 + ## 設定と自動化 UI はデフォルトでクライアントのローカルテーマ、サイドバー設定、キーバインドを使います。選択中サーバーが公開するカスタムコマンドとプラグインは、そのサーバーで実行します。Herdr はローカルのプラグイン、設定、実行ファイル、シークレットを SSH ホストにコピーしません。リモートにコマンドがなければエラーを表示します。クライアント設定を編集したら UI の `reload config` を使います。[設定](/ja/docs/configuration/)を参照してください。 @@ -107,6 +119,8 @@ UI はデフォルトでクライアントのローカルテーマ、サイド 保存するのは、不透明な ID、ラベル、SSH 接続先、明示的なリモートセッション名、有効状態だけです。パスワード、秘密鍵、エージェントのチケット、SSH 制御ソケットはカタログに保存しません。認証は OpenSSH が扱います。 +Herdr は、`--machine` コマンドを繰り返し実行するときに検出を省略できるよう、各マシンのリモート OS と解決済み実行ファイルパスを別途記憶します。既存のプロファイルでは、初回使用時に不足情報を学習します。このキャッシュは任意です。キャッシュファイルがない、不正、または書き込み不能でもコマンドの動作は妨げられません。コマンドは引き続き実行中サーバーの互換性を確認します。キャッシュした実行ファイルがない、または API 転送に対応しなくなった場合、最初の読み取り専用確認中に再検出します。リモートの状態をすでに変更した可能性があるコマンドを自動的に再実行することはありません。 + クライアントとサーバーは同一バージョンを要求せず、互換性を合意します。保存済みマシンへの接続には、サーバーの `surface_interest` と `health_check` 能力も必要です。未対応の古いサーバーは、単独接続できても明示的に更新するまで Attention になります。そのほかの未対応メソッドは、該当する操作だけを無効にします。 互換クライアントを更新しても、リモートサーバーを置き換えたりエージェントを停止したりしません。新しいサーバー機能が必要なら、そのサーバーを明示的に更新します。通常の置き換えでは、サーバーとペインのプロセスを停止する前に確認します。 diff --git a/docs/next/website/src/content/docs/ja/install.mdx b/docs/next/website/src/content/docs/ja/install.mdx index 587f6df1..c23e9fb5 100644 --- a/docs/next/website/src/content/docs/ja/install.mdx +++ b/docs/next/website/src/content/docs/ja/install.mdx @@ -149,6 +149,8 @@ Windows の通常利用には安定版を推奨します。プレビューでは herdr update --handoff ``` +両方のサーバーがエージェント状態の転送に対応している場合、ライブハンドオフはフックが報告したエージェント状態を次の報告が届くまで維持します。古いサーバーからハンドオフすると、実行中のエージェントが一時的に idle と表示される場合があります。 + ライブハンドオフは Homebrew、mise、Nix のパッケージマネージャー経由のアップデートには適用されません。それらのインストールではパッケージマネージャーで更新し、再度 Herdr を実行して更新済みクライアントで再接続します。互換性のある古いサーバーは動き続けます。新しいリリースのサーバー側変更が必要になったときだけ、`herdr server stop` または `herdr session stop <name>` で後から再起動してください。 ## 動作要件 diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index 126ba824..d39d4056 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -3,7 +3,7 @@ title: インテグレーション description: Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI、Grok CLI 向けの Herdr インテグレーションをインストールします。 --- -Herdr は対応エージェントを自動的に検出します。公式インテグレーションは、復元のためのネイティブセッション識別、ライフサイクル状態の報告、またはその両方を追加できます。 +Herdr は対応するエージェントを自動的に検出します。エージェントのインテグレーションをインストールすると、サーバーの再起動後に Herdr が同じセッションを resume できます。一部のインテグレーションはエージェントの状態も直接報告します。Herdr が対応するエージェントと、自ら Herdr に対応するエージェントについては[エージェント](/ja/docs/agents/)を参照してください。 エージェントネイティブのセッション復元、直接のライフサイクル報告、またはその両方が欲しいときにインテグレーションを使ってください。状態権威モデルの全体像は[エージェント](/ja/docs/agents/)を参照してください。 @@ -59,16 +59,9 @@ Herdr は Linux/macOS の統合設定と全プラットフォームの新規フ ## Herdr がインテグレーションをどう使うか -Herdr はインテグレーションを 2 つの異なる方法で使います: +どのインテグレーションも、エージェントが今どのセッションにいるかを Herdr に伝えるので、サーバーの再起動後に Herdr がそのセッションを resume できます。`[session] resume_agents_on_restore = false` で無効にできます。 -| インテグレーションの種類 | エージェント | 効果 | -| --- | --- | --- | -| ライフサイクル権威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | インストールされ、そのペインについて能動的に報告している間は、フックまたはプラグインのイベントが `idle`、`working`、`blocked` を決定します。同じライフサイクル権威に対して、Herdr はスクリーンマニフェストのフォールバックを併用しません。 | -| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 | - -カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。 - -一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 +Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode のインテグレーションは、エージェントの状態も報告します。報告している間、Herdr は画面を読む代わりにその報告を使います。その他のインテグレーションでは、状態は画面検出に任せます。 エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Qwen Code は `1`、Letta Code は `1`、Cursor Agent CLI は `1`、Grok CLI は `2`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 @@ -202,7 +195,7 @@ herdr integration install opencode この連携は OpenCode V1 `1.18.29` 以降と OpenCode V2(beta `19242` で検証済み)に対応します。OpenCode の設定ディレクトリはあらかじめ存在している必要があります。Herdr はサーバー側のエントリポイントを `~/.config/opencode/plugins/herdr-agent-state.js` に、共通 TUI プラグインを同じ設定ディレクトリの `herdr-tui-session.js` に、V2 TUI エントリポイントを `herdr-opencode/tui.js` にインストールします。 -インストール時、V1 TUI プラグインを `tui.jsonc` に、V2 TUI プラグインを `cli.json` に登録し、他の設定やプラグインは保持します。OpenCode に移行すべき V1 TUI 設定(`tui.json` または `kv.json`)が残っている場合、Herdr は初回起動時の移行を妨げないよう登録を見送ります。その場合は一度 `opencode2` を起動してから連携を再インストールしてください。それ以外の場合は Herdr が `cli.json` を作成してプラグインを登録します。インストール後は OpenCode TUI を再起動してください。アンインストールは管理対象のプラグインファイルと設定項目を削除します。 +インストール時、V1 TUI プラグインを `tui.jsonc` に、V2 TUI プラグインを `cli.json` に登録し、他の設定やプラグインは保持します。`tui.json` に既存の V1 登録がある場合もインストール済みとみなし、`tui.jsonc` を作成せずに再利用します。アンインストールでは、両方の TUI 設定ファイルから Herdr の登録を削除し、ファイルと他の設定は保持します。OpenCode に移行すべき V1 TUI 設定(`tui.json` または `kv.json`)が残っている場合、Herdr は初回起動時の移行を妨げないよう登録を見送ります。その場合は一度 `opencode2` を起動してから連携を再インストールしてください。それ以外の場合は Herdr が `cli.json` を作成してプラグインを登録します。インストール後は OpenCode TUI を再起動してください。アンインストールは管理対象のプラグインファイルと設定項目を削除します。 V2 のライフサイクル報告はペイン内の TUI で実行され、複数のペインが同じ OpenCode サーバーを共有する場合も、選択されたルートセッションにイベントを対応付けます。完了時または中断時に working 状態を解除し、未処理の権限要求やフォーム、実行失敗は blocked 状態を維持します。V2 Mini とヘッドレスクライアントは TUI プラグインを実行しないため、このライフサイクル報告は利用できません。 diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index 361f5222..a03c52ce 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -486,6 +486,8 @@ Herdr はローカルソケット上の改行区切り JSON を使います。Un {"id":"req_1","result":{"type":"pong"}} ``` +JSON のデコードまで到達したリクエスト行では、メソッドやパラメーターが無効な場合も含め、エラーレスポンスにもリクエストの `id` がそのまま含まれます。JSON が無効、またはトップレベルの文字列 `id` を一意に特定できない場合、エラーレスポンスは空の `id` を使います。無効な UTF-8 などのトランスポートエラーでは、レスポンスを返さずに接続を閉じる場合があります。 + イベント購読は、最初のレスポンスの後も接続を開いたままにします。 ## ソケットパス @@ -545,7 +547,7 @@ Herdr はローカルソケット上の改行区切り JSON を使います。Un } ``` -どちらのメソッドも、Herdr サーバーの再起動後に報告元の現在のセッションを再開するコマンドを、省略可能な `resume_argv` 配列で受け付けます。最初の要素はパスではなく単純なコマンド名でなければならず、どの要素にもアポストロフィを含めることはできません。無効な値は `invalid_resume_argv` で失敗し、その報告は適用されません。カスタムエージェントは、コマンドを付ける前に `pane.report_agent` でペインを保持している必要があり、そうでない場合は `resume_not_accepted` で失敗します。Herdr は同じ `source` と `agent` がペインを保持している間だけコマンドを保持し、そのエージェントに対する Herdr 組み込みの resume コマンドより優先します。古い Herdr はこのフィールドを無視します。 +どちらのメソッドも、Herdr サーバーの再起動後に報告元の現在のセッションを再開するコマンドを、省略可能な `resume_argv` 配列で受け付けます。配列は最大 64 要素、合計 8 KiB までです。最初の要素はパスではなく単純なコマンド名でなければならず、どの要素にもアポストロフィや制御文字を含めることはできません。無効な値は `invalid_resume_argv` で失敗し、その報告は適用されません。カスタムエージェントは、コマンドを付ける前に `pane.report_agent` でペインを保持している必要があり、そうでない場合は `resume_not_accepted` で失敗します。Herdr は同じ `source` と `agent` がペインを保持している間だけコマンドを保持し、そのエージェントに対する Herdr 組み込みの resume コマンドより優先します。古い Herdr はこのフィールドを無視します。 ```json { @@ -642,7 +644,9 @@ workspace の get/list 応答は結果の `tokens` マップを公開し、ス } ``` -最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。ライフサイクルイベントの購読はリクエストが受理された時点から始まり、それ以前に保持されていたイベントは再生しません。 +最初のレスポンスは購読の確認応答です。以降の行はプッシュされるイベントです。 +すべての項目が有効でなければなりません。参照先ペインが 1 つでも存在しない場合、Herdr はリクエスト全体をエラーで拒否し、接続を閉じます。他の項目が購読されたと仮定せず、ペイン一覧を更新して再試行してください。 +ライフサイクルイベントの購読はリクエストが受理された時点から始まり、それ以前に保持されていたイベントは再生しません。 ライフサイクルとエージェント状態のイベントは、各購読内の順序を保った上限付きのバッチで送信されます。イベント履歴は永続ログではありません。購読の初期化中も含め、購読者が保持範囲より遅れた場合、サーバーは元のリクエスト `id` と `error.code: "events_lost"` を含むエラーレスポンスを送り、その購読接続を閉じます。欠落したイベントを通知せずに配信を続けることはありません。他のクライアント接続には影響しません。履歴はイベント種別間で共有されるため、失われたイベントが購読条件に一致しなかった可能性があっても、保持範囲を超えたことを通知します。 diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 9e844d7e..5becbd8e 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -687,7 +687,7 @@ Session-only official integrations report native session references with `pane.r } ``` -Both methods accept an optional `resume_argv` array with the command that resumes the reporter's current session after a Herdr server restart. Its first element must be a plain command name, not a path, and no element may contain an apostrophe; invalid values fail with `invalid_resume_argv` and the report is not applied. A custom agent must hold the pane through `pane.report_agent` before it can attach a command; otherwise the request fails with `resume_not_accepted`. Herdr keeps the command only while the same `source` and `agent` hold the pane, and it takes precedence over Herdr's built-in resume command for that agent. Older Herdr versions ignore the field. +Both methods accept an optional `resume_argv` array with the command that resumes the reporter's current session after a Herdr server restart. Its first element must be a plain command name, not a path. It allows at most 64 elements and 8 KiB in total, and no element may contain an apostrophe or control character; invalid values fail with `invalid_resume_argv` and the report is not applied. A custom agent must hold the pane through `pane.report_agent` before it can attach a command; otherwise the request fails with `resume_not_accepted`. Herdr keeps the command only while the same `source` and `agent` hold the pane, and it takes precedence over Herdr's built-in resume command for that agent. Older Herdr versions ignore the field. ```json { diff --git a/docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx b/docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx index 827fd233..bb7f1386 100644 --- a/docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx +++ b/docs/next/website/src/content/docs/zh-cn/add-herdr-support.mdx @@ -67,10 +67,11 @@ Herdr 窗格中的每个进程都会继承这些环境变量: Herdr 服务器重启后,Herdr 会在同一目录中打开该窗格并在其中运行这条命令。命令必须遵守以下规则: - 第一个词是用户 `PATH` 上的普通命令名,例如 `my-agent`,不能是路径。 -- 任何参数都不能包含撇号。 +- 任何参数都不能包含撇号或控制字符。 +- 最多 64 个参数,总长度不超过 8 KiB。 - 你的来源必须先持有该窗格,所以请在发送命令之前或同时用 `report-agent` 上报状态。否则 Herdr 会返回 `resume_not_accepted`。 -违反前两条规则的命令会被 Herdr 以 `invalid_resume_argv` 拒绝,该次上报也不会生效。用户可以用 `[session] resume_agents_on_restore = false` 关闭恢复。 +违反前三条规则的命令会被 Herdr 以 `invalid_resume_argv` 拒绝,该次上报也不会生效。用户可以用 `[session] resume_agents_on_restore = false` 关闭恢复。 ## 退出时释放 @@ -89,7 +90,7 @@ Herdr 服务器重启后,Herdr 会在同一目录中打开该窗格并在其中 - 不要让 Herdr 拖慢你的智能体。在后台或用较短的超时发送上报,并忽略失败。 - 只发送最新状态。如果一次上报正在发送时积累了多个变化,丢弃较旧的那些。 -- 旧版 Herdr 会忽略恢复命令,其他功能照常工作。 +- 恢复命令需要 Herdr 0.10.0 或更高版本。旧版会忽略它,状态上报和释放照常工作。 ## 直接使用套接字 diff --git a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx index 8ad27952..9476c65c 100644 --- a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx @@ -77,6 +77,8 @@ herdr agent rename w1:p2 reviewer `idle` 和 `done` 都表示可以接受输入。CLI/API 使用服务器的已查看状态:`done` 表示尚未标记为已查看的 idle;显式 `pane focus` / `agent focus` 会标记目标,读取不会。每个 TUI 客户端独立记录它已显示的完成状态,因此其 Done 标记可能与 CLI 或其他客户端不同。`blocked` 表示 Herdr 识别到了审批或提问界面。`unknown` 表示智能体存在,但 Herdr 无法可靠判断其生命周期;它不代表工作成功完成。区别重要时,请指定精确的 `--until` 状态。 +启动就绪、恢复空闲对话和切换智能体对话都不算工作完成。立即开始工作的智能体可以在 Herdr 首次看到空闲提示前完成第一个任务。智能体响应可能包含 `completion_seq`,它会独立于谁查看过响应,将当前 idle 转换标识为已完成的工作。该值与此次转换的 `state_change_seq` 相同;启动和会话切换不会设置它。旧服务器可能省略此字段。连接到旧服务器的客户端使用观察到的 working 状态来保护 Done 标记,因此可能漏掉在两次更新之间开始并完成的轮次。 + 超时或 `agent_prompt_stalled` 并不意味着没有发送输入。重试前请先读取智能体,避免重复提交同一提示。ID 和智能体名称只在所属服务器内有效;在 TUI 中切换机器不会改变已有窗格中 CLI 命令的目标。 `pane wait-output` 不解释智能体生命周期。它轮询选定的终端快照并立即进行第一次搜索,所以已经存在的文本也会匹配。默认来源名是 `recent`;匹配时会把最近 80 个已渲染终端行作为未折行输出处理。`--lines` 可以修改这个行数限制;`--regex` 使用 Rust 正则表达式语法并逐行匹配。 diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx index 13ec4998..a3018276 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -1,6 +1,6 @@ --- title: 智能体 -description: 了解 Herdr 能检测什么、智能体状态如何工作,以及集成如何改进它。 +description: 了解哪些编程智能体可以配合 Herdr 使用(由 Herdr 支持,或自身支持 Herdr),以及智能体状态如何工作。 --- Herdr 为同时运行多个编程智能体而生。每个智能体都待在一个真实的终端窗格里,shell、日志、提示符和运行中的进程都完好无损。Herdr 跟踪哪些窗格里有智能体,把它们的状态汇总到标签页和工作区,让你直接跳到需要关注的窗格,而不是手动轮询每个终端。 @@ -9,46 +9,58 @@ Herdr 为同时运行多个编程智能体而生。每个智能体都待在一 ## 受支持的智能体 -常见编程智能体开箱即用地支持自动检测。重要的区别不在于 Herdr 能不能看到某个智能体,而在于允许哪个信号来决定 `idle`、`working` 和 `blocked`。 +智能体获得 Herdr 支持有两种方式:由 Herdr 支持它,或由它自己支持 Herdr。无论哪种方式,你都能看到每个智能体的 `idle`、`working` 和 `blocked` 状态,在它完成或需要你处理时收到通知,并能在脚本中等待它。 -| 智能体 | 状态权威 | 集成角色 | +其他智能体在 Herdr 窗格中照常运行,只是显示为普通终端。 + +### 由 Herdr 支持 + +Herdr 能识别这些智能体,并从它们在屏幕上绘制的内容读取状态。要在 Herdr 服务器重启后回到同一个会话,请用 `herdr integration install <name>` 安装该智能体的集成。每个集成会安装什么,见[集成](/zh-cn/docs/integrations/)。 + +| 智能体 | 集成 | 说明 | | --- | --- | --- | -| Pi | 安装后为生命周期钩子;否则为屏幕清单 | 状态与会话 | -| OMP | 安装后为生命周期钩子 | 状态与会话 | -| GitHub Copilot CLI | 屏幕清单 | 会话 | -| Devin CLI | 屏幕清单 | 会话 | -| Kimi Code CLI | 安装后为生命周期钩子;否则为屏幕清单 | 状态与会话 | -| Hermes Agent | 屏幕清单 | 会话 | -| Qoder CLI | 屏幕清单 | 会话 | -| Qwen Code | 屏幕清单 | 会话 | -| Letta Code | 屏幕清单 | 会话 | -| Droid | 屏幕清单 | 会话 | -| OpenCode | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | -| Kilo Code CLI | 安装后为生命周期插件;否则为屏幕清单 | 状态与会话 | -| MastraCode | 安装后为生命周期钩子 | 状态与会话 | -| Claude Code | 屏幕清单 | 会话 | -| Codex | 屏幕清单 | 会话 | -| Cursor Agent CLI | 屏幕清单 | 会话 | -| Amp | 屏幕清单 | 无 | -| Grok CLI | 屏幕清单 | 会话 | -| Antigravity CLI | 屏幕清单 | 会话 | -| Kiro CLI | 屏幕清单 | 无 | -| Maki | 屏幕清单 | 无 | -| Muse | 屏幕清单 | 无 | - -可检测但测试较少: Gemini CLI 和 Cline。不受支持的智能体仍然可以作为普通终端进程正常运行,只是在你添加集成或通过 socket API 上报状态之前,可能得不到丰富的状态。 - -## 状态权威 - -Herdr 首先检测每个窗格的前台进程。之后,每个窗格有且只有一个状态权威。 - -对于具备完整生命周期钩子的智能体,当集成已安装并在为运行中的窗格主动上报时,集成就是权威。Herdr 用这些钩子上报来决定 `idle`、`working`、`blocked` 和会话身份。对同一个生命周期权威,它不会再并行运行屏幕清单兜底。这样可以避免出现两个相互竞争的事实来源。 - -对于没有完整生命周期钩子的智能体,Herdr 识别前台进程,并读取实时的底部缓冲区屏幕快照。它对该快照评估 TOML 清单,来判定 `idle`、`working` 和 `blocked`。对于会发出这些信号的智能体,清单也可以把终端标题和进度 (OSC) 序列作为检测证据;当这些证据不存在时,屏幕规则独自承担检测。 - -屏幕快照来自窗格缓冲区最近的底部,而不是滚动后的视口。即使你在 Herdr 里向上翻页,检测仍然跟随底部的实时智能体 UI。 - -上表中标为“会话”的集成有意不作为生命周期权威。它们为恢复提供原生会话身份,但它们的钩子并不覆盖整个生命周期,可能漏掉权限审批结果、Esc 中断或其他状态转换。对这些智能体,Herdr 仍然使用屏幕清单检测。 +| Claude Code | `claude` | | +| Codex | `codex` | | +| GitHub Copilot CLI | `copilot` | | +| Cursor Agent CLI | `cursor` | | +| OpenCode | `opencode` | 也上报状态 | +| Pi | `pi` | 也上报状态 | +| OMP | `omp` | 状态需要集成 | +| Droid | `droid` | | +| Devin CLI | `devin` | | +| Kimi Code CLI | `kimi` | 也上报状态 | +| Kilo Code CLI | `kilo` | 也上报状态 | +| Hermes Agent | `hermes` | | +| Qoder CLI | `qodercli` | | +| Qwen Code | `qwen` | | +| Letta Code | `letta` | 仅支持 CLI 安装 | +| MastraCode | `mastracode` | 状态需要集成 | +| Grok CLI | `grok` | | +| Antigravity CLI | `antigravity-cli` | | +| Amp | 无 | 仅状态 | +| Kiro CLI | 无 | 仅状态 | +| Maki | 无 | 仅状态 | +| Gemini CLI | 无 | 仅状态,测试较少 | +| Cline | 无 | 仅状态,测试较少 | + +### 由智能体自身支持 + +这些智能体会自己向 Herdr 上报状态。无需安装任何东西,在 Herdr 窗格中运行即可。 + +- [Crush](https://github.com/charmbracelet/crush) +- Command Code +- Muse +- [Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent) + +同时上报恢复命令的智能体,会在 Herdr 服务器重启后回到同一个会话。 + +如果你在开发编程智能体,[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)介绍了如何加入这个列表。只需调用几次 Herdr 的 CLI 或 socket,不需要修改 Herdr。 + +## 状态如何判定 + +对于由 Herdr 支持的智能体,Herdr 在每个窗格中找到智能体进程,并读取窗格的实时底部,而不是你滚动到的位置。检测清单中的规则决定该屏幕表示 `idle`、`working` 还是 `blocked`。如果集成也上报状态,Herdr 会使用这些上报,而不是读取屏幕。 + +对于自身支持 Herdr 的智能体,由智能体自己的上报决定状态。 ## 虚拟机与沙箱包装器 @@ -58,9 +70,11 @@ Herdr 首先检测每个窗格的前台进程。之后,每个窗格有且只有 ## blocked 状态 -对屏幕清单类智能体,blocked 检测刻意从严。只有当实时底部缓冲区快照匹配已知可见的审批、提问或权限 UI 时,Herdr 才标记 `blocked`。对已知智能体,如果没有任何清单规则匹配,Herdr 会回退到 `idle`,并在 explain 输出中把该回退标记为 `default_known_agent_idle_fallback`。 +对屏幕清单类智能体,blocked 检测刻意从严。只有当实时底部缓冲区快照匹配已知可见的审批、提问或权限 UI 时,Herdr 才标记 `blocked`。对于 Codex 以外的已知智能体,如果没有任何清单规则匹配,Herdr 会回退到 `idle`,并在 explain 输出中把该回退标记为 `default_known_agent_idle_fallback`。Codex 会回退到 `unknown`,因为在活跃轮次期间和响应结束后,它的标题和输入框可能看起来相同。 -这意味着不常见的新智能体提示可能一开始显示为 `idle` 而不是 `blocked`,直到 Herdr 学会那种屏幕形态。这些交互不会让 Herdr 发送输入或执行破坏性操作;它们只影响可见状态和等待。 +对于其他智能体,不常见的新提示可能一开始显示为 `idle` 而不是 `blocked`,直到 Herdr 学会那种屏幕形态。误判只影响可见状态和等待,不会让 Herdr 发送输入或执行破坏性操作。 + +对于 Codex,可见的旋转指示符或实时活动计时器可以确定 `working`,可见的审批提示可以确定 `blocked`。普通标题、输入框或没有旋转指示符都不能确定一个轮次已经结束。因此,Codex 在响应后可能继续保持 `unknown`,等待 `idle` 或完成也可能超时。托管启动仅使用初始输入框来判断它何时可以接收提示,该观察不会改变轮次状态。 ## 检测清单 @@ -105,17 +119,6 @@ fi 这就是 Herdr 的主要工作流: 启动多个智能体,让它们并行工作,用侧边栏看哪个项目需要决策、哪个还在运行、哪个已经可以审阅。 -## 直接集成 - -为你使用的每个智能体安装集成;它让 Herdr 获得钩子或插件上报,而不是只靠屏幕检测: - -```bash -herdr integration install claude -herdr integration status -``` - -每个受支持的智能体都有自己的集成名称和行为。按智能体的细节和完整安装列表见[集成](/zh-cn/docs/integrations/)。如果你正在构建智能体,[为你的智能体添加 Herdr 支持](/zh-cn/docs/add-herdr-support/)介绍了如何在不为 Herdr 添加原生支持的情况下上报生命周期状态和恢复命令。 - ## 自定义智能体标签 你可以重命名智能体目标的显示名: diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index ae5c3ab4..c71c78c5 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -49,6 +49,7 @@ herdr api schema --output herdr-api.schema.json ```bash herdr machine list +herdr machine add workbox herdr machine add workbox --label "Build machine" herdr machine rename <profile-id> --label "New name" herdr machine disable <profile-id> @@ -56,7 +57,7 @@ herdr machine enable <profile-id> herdr machine remove <profile-id> ``` -`machine add` 默认使用远程默认会话。只有需要命名会话时才添加 `--remote-session <name>`。`machine list` 支持可选的 `--json` 供脚本使用。完整流程见[连接机器](/zh-cn/docs/connecting-machines/)。 +在交互式终端中,`machine add` 会发现运行中的会话,并在有多个会话运行时让你选择。没有运行中的会话或未安装 Herdr 时使用 `default`;如果查询已安装的二进制文件失败,则需要明确选择。添加 `--remote-session <name>` 可跳过发现。非交互式命令会使用 `default`,除非提供该选项。`machine list` 支持可选的 `--json` 供脚本使用。完整流程见[连接机器](/zh-cn/docs/connecting-machines/)。 `machine add` 会检查远程 Herdr 是否具备所需能力,仅在必要时征得批准后安装或更新,并在保存配置前启动指定会话的后台服务器。只要兼容,发行版本不必一致。需要批准安装或重启时,请在交互式终端中运行;设置失败或取消时不会保存配置。替换运行中的服务器需要明确批准,默认回答为 No;停止服务器会结束其窗格进程。`machine add` 不会隐式启用实验性实时交接。 @@ -473,6 +474,8 @@ herdr plugin pane close <pane_id> `plugin pane open` 要求插件已链接、已启用并与当前平台兼容。它把清单声明的 `[[panes]]` 命令作为 Herdr 管理的终端窗格启动。清单的默认值是 `overlay`,在活动窗格上方打开一个临时的缩放覆盖层。它也可以作为分割、新标签页、缩放窗格,或不改变标签页布局的会话级模态 `popup` 打开。`--width` 和 `--height` 以终端单元格数或 `80%` 这样的百分比设置弹窗外层尺寸;省略时默认为终端大小的一半,过小的值会限制为弹窗最小尺寸。弹窗不是 Herdr 窗格,不会收到 `HERDR_PANE_ID`,也不参与 pane 或智能体 API。非终端的原生插件窗格是之后的能力面。 +新窗格使用 `TERM_PROGRAM=herdr` 标识自身,并将 `TERM_PROGRAM_VERSION` 设为 Herdr 的版本。它们保留 `TERM=xterm-256color` 和 `COLORTERM=truecolor`。应用显式的启动环境值之前,Herdr 会移除继承的外层终端会话标记(iTerm2、WezTerm、Kitty、Windows Terminal、tmux、screen 和 Zellij)、iTerm2 的 `LC_TERMINAL` 身份,以及已知的 Claude Code、Codex 和 OMP 会话标记。这可防止新窗格冒充最初启动服务器的终端或智能体会话。API 密钥和显示设置等普通环境变量仍会继承,而不会被连接客户端的环境替换。SSH 智能体转发有自己的重连处理。现有窗格进程不受此清理影响。 + `--env KEY=VALUE` 可以在启动进程的命令上重复使用,只作用于新启动的进程。当与调用方提供的环境变量冲突时,`HERDR_SOCKET_PATH`、`HERDR_BIN_PATH`、`HERDR_ENV`、`HERDR_WORKSPACE_ID`、`HERDR_TAB_ID`、`HERDR_PANE_ID`、`HERDR_PLUGIN_ID`、`HERDR_PLUGIN_ROOT`、`HERDR_PLUGIN_CONFIG_DIR`、`HERDR_PLUGIN_STATE_DIR`、`HERDR_PLUGIN_ENTRYPOINT_ID` 和 `HERDR_PLUGIN_CONTEXT_JSON` 等 Herdr 管理的变量保持权威。 ## 读取来源 diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 8b8738d6..5885a7ff 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -73,7 +73,7 @@ headless_rows = 50 default_shell = "nu" ``` -未设置或值为空时,Herdr 会依次使用 `$SHELL`、Unix 上的 `/bin/sh` 或 Windows 上的 PowerShell。该值是可执行文件名或路径,而不是 shell 命令行。现有窗格会保留当前 shell,直到重新创建。自定义命令按键绑定中的字符串在 Unix 上通过 `/bin/sh -c` 执行窗格命令,通过 `/bin/sh -lc` 执行分离式命令;在 Windows 上则通过 `cmd.exe /d /c` 执行。 +未设置或值为空时,Herdr 会在 Unix 上依次使用 `$SHELL` 和 `/bin/sh`。在 Windows 上,如果能从 `PATH` 解析到 `pwsh.exe`(PowerShell 7),则使用它,否则使用系统自带的 `powershell.exe`(Windows PowerShell 5.1)。该值是可执行文件名或路径,而不是 shell 命令行。现有窗格会保留当前 shell,直到重新创建。自定义命令按键绑定中的字符串在 Unix 上通过 `/bin/sh -c` 执行窗格命令,通过 `/bin/sh -lc` 执行分离式命令;在 Windows 上则通过 `cmd.exe /d /c` 执行。 设置 Herdr 如何启动新建交互式窗格的 shell: @@ -142,6 +142,15 @@ navigate_pane_down = "ctrl+j" split_horizontal = "prefix+minus" ``` +需要通过多个按键进入前缀模式时,`keys.prefix` 也接受数组。例如,可让 macOS 和远程 Linux 机器共享同一份配置: + +```toml +[keys] +prefix = ["ctrl+space", "ctrl+s"] +``` + +配置的每个前缀都会进入同一个前缀模式,并可触发所有 `prefix+...` 动作。第一项是状态栏中显示的主前缀;应用内 `prefix+?` 帮助面板会列出全部前缀。 + 默认键位以前缀为主,因此 Herdr 不会抢占 shell、编辑器、tmux 或终端应用的输入。在[配置参考](/docs/config-reference/)中搜索 `keys.` 可查看每个动作及其默认绑定。应用内帮助面板可通过 `prefix+?` 打开,其中会显示当前生效的绑定。 当一个动作需要多个快捷键时,绑定也可以是数组: @@ -244,6 +253,8 @@ name = "catppuccin" 在[配置参考](/docs/config-reference/)中搜索 `theme.name` 可查看所有内置主题。如果想让 Herdr UI 颜色跟随宿主终端的 ANSI 调色板,请使用 `terminal`。 +在 Unix 上,Herdr 还会在调整大小或收到 `SIGWINCH` 后重绘时重新读取宿主终端的颜色。如果主题切换器通过 OSC 更改终端颜色却不发送明暗通知,请在应用新调色板后执行 `kill -WINCH <client-pid>`。目标应是已连接的 Herdr 客户端,而不是服务器;现有窗格会继续运行。直接通过 `herdr terminal attach` 连接的会话会把调色板查询交给所连接的应用。 + 要让 Herdr 在宿主终端报告明暗外观变化时自动切换自身的 UI 主题,请启用主题自动切换: ```toml @@ -508,9 +519,19 @@ claude = "on" ## Kitty graphics -Herdr 默认在兼容的外层终端中渲染窗格图像。图像放置区域与弹窗、菜单或通知重叠时,Herdr 会暂时隐藏整个图像放置区域,而不是只裁剪重叠部分。未被遮挡的图像保持可见,被隐藏的图像放置区域在遮挡消失后恢复显示。图像不会随对话框背景一起变暗。 +应用和插件通过窗格终端输出中发出的标准 Kitty graphics 协议进行集成。Herdr 默认在兼容的外层终端中渲染这些图像。外部 socket 窗格覆盖层 API 已被移除,也没有替代的 socket 图像 API。 + +弹窗、菜单和通知会在其覆盖区域周围裁剪图像;每幅图像的其余部分仍然可见,覆盖层关闭后,被遮挡的部分会恢复显示。图像不会随对话框背景一起变暗。 + +Herdr 会自动选择符合条件的文件传输方式,无需更改应用或切换环境变量。在本地 Unix Ghostty 客户端上,它可以通过临时文件发送图像数据,而不是将其编码到终端输出中。系统会先用一个小型文件读取探测来检查支持情况。其他终端、通过 SSH 启动的客户端和嵌套多路复用器仍使用内联输出进行最后一跳优化。 + +对于符合条件的本地客户端,原生 RGBA 图像从服务器传到客户端时使用 Herdr 所有的快照路径,而不是像素字节。不支持的客户端和失败的传输会回退到内联传送。远程服务器路径绝不会转发到本地终端。图像放置、裁剪和可见性仍由 Herdr 控制。 + +在 Linux 上,Herdr 读取整文件、未压缩 RGBA 上传的像素前,可以使用写时复制快照。这要求文件系统支持克隆到 Herdr 的私有 `/var/tmp` 存储。不支持的文件系统、临时文件上传、共享内存和其他格式会使用普通加载器。每个快照上限为 16 MiB,总上限为 64 MiB;动画和不受支持的客户端会在需要时读取像素。Herdr 接受图像后绝不会依赖可变的生产者路径。 + +PNG 上传仍会进行完整解码和验证。Herdr 不会把损坏图像的验证推迟到外层终端。 -要禁用图形渲染和窗格图形 API,请设置: +要禁用 Kitty graphics 渲染,请设置: ```toml [terminal] @@ -519,7 +540,7 @@ kitty_graphics = false 为兼容现有配置,旧的 `experimental.kitty_graphics` 设置仍然可用。若两者同时设置,`terminal.kitty_graphics` 优先。 -更改此设置后,需要重启受影响的 Herdr 服务器或重新连接客户端。在远程会话中,服务器端设置控制窗格图形解析和 API 可用性,本地客户端设置控制向外层终端输出图形。 +更改此设置后,需要重启受影响的 Herdr 服务器或重新连接客户端。在远程会话中,服务器端设置控制 Kitty graphics 解析,本地客户端设置控制向外层终端输出图形。 ## 智能体会话恢复 diff --git a/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx b/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx index 722adeed..116cecbb 100644 --- a/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx +++ b/docs/next/website/src/content/docs/zh-cn/connecting-machines.mdx @@ -7,6 +7,8 @@ description: 在一个 Herdr 窗口中使用 Local 和已保存的 SSH 机器, 每台机器保留自己的 Herdr 服务器、会话和进程。一台机器断线不会影响其他连接。 +Herdr 会请求 SSH 压缩,以减少受限连接上的屏幕更新流量。这适用于较旧的兼容 Herdr 服务器,也不会改变按键或其他输入的传送方式。Herdr 的 `-C` 选项会覆盖 SSH 配置中的 `Compression no`。如果 SSH 通过外部 ControlMaster 复用现有连接,该连接会保留原来的压缩设置。 + ### 恢复 SSH 认证 在 Linux 和 macOS 上,启用 `remote.manage_ssh_config=true` 后,添加机器、侧边栏和 API 操作共享已批准的 OpenSSH 连接。运行 `herdr machine status [<label-or-id>] [--json]` 可进行新的非交互式检查。**reachable** 表示远程 Herdr 当前可用,不代表每个 TUI 都已连接。在终端运行 `herdr machine reconnect <label-or-id>` 完成原生 SSH 认证并验证已保存的机器;该命令不会安装或更新 Herdr。已打开的客户端会在 30 秒内重试,无需重启。点击 **`! auth`**、**`! error`** 或失败后的重连状态,会显示包含最新错误及 CLI 命令的通知,不会打开认证弹窗。机器行其余部分仍用于展开或折叠。 @@ -30,17 +32,23 @@ ssh workbox 请在交互式终端中运行,以便 Herdr 在安装或替换内容之前询问: ```bash -herdr machine add workbox --label "Build machine" +herdr machine add workbox ``` -这会使用远程默认会话。一个机器配置只对应一个远程会话,不会汇总该主机的所有会话。 +在交互式终端中,Herdr 会发现运行中的会话:只有一个会话运行时自动选择;有多个时,可用 Up/Down 选择并按 Enter 确认。按 Esc 或 Ctrl+C 取消。查询成功但没有运行中的会话时,设置使用 `default`。未找到 Herdr 安装时,设置会提议为 `default` 安装。如果已安装的二进制文件无法报告会话,设置会报告错误;可传入 `--remote-session <name>` 明确选择会话。 + +默认会话在侧边栏中显示为 `workbox`,即去掉所有 `user@` 前缀的 SSH 主机名。添加 `--label "Build machine"` 可选择其他名称。一个机器配置只对应一个远程会话,不会汇总该主机的所有会话。非交互式命令使用 `default`,除非提供 `--remote-session`。 -如需命名会话,再添加可选的 `--remote-session`: +要跳过发现并使用指定会话,请添加 `--remote-session`: ```bash -herdr machine add workbox --label "Build machine" --remote-session agents +herdr machine add workbox --remote-session agents ``` +未提供 `--label` 时,这台机器显示为 `workbox/agents`。如果默认名称已被占用,`machine add` 会要求你传入 `--label`。 + +在 Windows 服务器上,设置还会检查 SSH 用户拥有的 Herdr 进程所使用的可执行文件。它会先验证其能力再复用,因此即使 SSH 的 `PATH` 指向较旧的安装,也能使用兼容的运行中构建。 + Herdr 分别检查已安装的二进制文件和运行中的服务器,并在保存配置之前启动目标后台服务器。客户端和服务器版本只要兼容,就不必一致。未安装或不兼容时会进入需要批准的设置流程。如果必须替换运行中的服务器,会先询问是否停止它及其窗格进程,再启动兼容的服务器。默认回答为 No;同时需要安装和替换时,一次确认涵盖两项操作。`machine add` 不使用实验性的实时交接。取消或设置失败时,不会保存配置。 运行 `herdr` 打开 UI。如果本地客户端已经打开,新增或启用的机器通常会在一秒内出现并在后台连接,不改变当前选择。切换机器期间的配置变化会在切换完成后应用。设置结束后,远程服务器仍会运行。 @@ -95,6 +103,10 @@ herdr --remote workbox 认证失败时,先检查普通 SSH。对于有口令的私钥,请在启动 Herdr 的非交互式后台连接前用 `ssh-add` 加载。 +如需在远程窗格中进行 Git 认证或 SSH 签名,请在 SSH 配置中为可信主机启用 `ForwardAgent yes`。Herdr 不会替你启用转发。如果会话启动时带有智能体,更新后的 Linux 和 macOS 服务器会为窗格提供稳定的智能体地址,因此在 `--remote` 或已保存机器重新连接后,现有窗格和新窗格都能使用转发的智能体。可用的继承智能体或较早的连接优先于后续客户端和临时设置检查;如果它消失,另一个实时连接可以提供智能体。 + +这需要更新远程服务器,而不只是本地客户端。未带智能体启动的本地会话不会更改 `SSH_AUTH_SOCK`。在服务器更新前或会话首次获得智能体前创建的窗格会保留原来的环境,需要重新创建一次才能继承稳定地址。较旧的兼容服务器和未转发智能体的连接仍可正常连接。 + ## 设置与自动化 UI 默认使用客户端本地的主题、侧边栏设置和按键绑定。选中服务器公布的自定义命令和插件仍在那里运行。Herdr 不会将本地插件、配置、可执行文件或机密复制到 SSH 主机。远程缺少命令时会明确报错。编辑客户端设置后,使用 UI 中的 `reload config`;参见[配置](/zh-cn/docs/configuration/)。 @@ -107,6 +119,8 @@ UI 默认使用客户端本地的主题、侧边栏设置和按键绑定。选 配置只保存不透明 ID、标签、SSH 目标、明确的远程会话名和启用状态。Herdr 不会将密码、私钥、智能体票据或 SSH 控制套接字存入机器目录。认证仍由 OpenSSH 处理。 +Herdr 会另外记住每台机器的远程操作系统和解析后的可执行文件路径,让重复的 `--machine` 命令可以跳过发现。现有配置会在首次使用时补充缺失信息。该缓存是可选的:缓存文件缺失、无效或不可写都不会妨碍命令运行。命令仍会检查实时服务器兼容性。如果缓存的可执行文件不存在或不再支持 API 转发,Herdr 会在最初的只读检查期间重新发现它;对于可能已经更改远程状态的命令,Herdr 绝不会自动重试。 + 客户端与服务器协商兼容性,不要求版本完全相同。保存机器的连接还需要服务器支持 `surface_interest` 和 `health_check` 能力。缺少这些能力的旧服务器,即使能独立连接,也会显示 Attention,直到明确更新。其他缺失的方法只禁用对应操作。 更新兼容客户端不会替换远程服务器或停止其智能体。需要新的服务器行为时,请明确更新该服务器。正常替换会在停止服务器及其窗格进程之前询问。 diff --git a/docs/next/website/src/content/docs/zh-cn/install.mdx b/docs/next/website/src/content/docs/zh-cn/install.mdx index 93885224..1d44c225 100644 --- a/docs/next/website/src/content/docs/zh-cn/install.mdx +++ b/docs/next/website/src/content/docs/zh-cn/install.mdx @@ -149,6 +149,8 @@ Windows 的常规使用建议选择稳定版。预览版能更早获得修复, herdr update --handoff ``` +当两个服务器都支持智能体状态传输时,实时交接会保留钩子上报的智能体状态,直到下一次上报到达。从较旧服务器交接时,运行中的智能体可能暂时显示为 idle。 + 实时交接不适用于 Homebrew、mise 或 Nix 的包管理器更新。这些安装先用包管理器更新,再重新运行 Herdr,用更新后的客户端连接。兼容的旧服务器会继续运行。只有需要新版本的服务器端改动时,才稍后用 `herdr server stop` 或 `herdr session stop <name>` 重启它。 ## 系统要求 diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index 0fc3d2c0..20ca0aab 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -3,7 +3,7 @@ title: 集成 description: 为 Pi、OMP、Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Kimi Code CLI、OpenCode、Kilo Code CLI、Hermes Agent、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、MastraCode、Antigravity CLI 和 Grok CLI 安装 Herdr 集成。 --- -Herdr 自动检测受支持的智能体。官方集成可以额外提供用于恢复的原生会话身份、生命周期状态上报,或两者兼有。 +Herdr 会自动检测它支持的智能体。安装智能体的集成后,Herdr 可以在服务器重启后恢复同一个会话。部分集成还会直接上报智能体的状态。哪些智能体由 Herdr 支持、哪些智能体自己支持 Herdr,见[智能体](/zh-cn/docs/agents/)。 当你想要智能体原生会话恢复、直接生命周期上报,或两者都要时,使用集成。完整的状态权威模型见[智能体](/zh-cn/docs/agents/)。 @@ -59,16 +59,9 @@ Herdr 对 Linux/macOS 上的集成配置和所有平台的新文件使用原子 ## Herdr 如何使用集成 -Herdr 以两种不同方式使用集成: +每个集成都会告诉 Herdr 智能体当前所在的会话,这样 Herdr 就能在服务器重启后恢复它。用 `[session] resume_agents_on_restore = false` 可以关闭。 -| 集成类型 | 智能体 | 效果 | -| --- | --- | --- | -| 生命周期权威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | 已安装且在为该窗格主动上报时,由钩子或插件事件决定 `idle`、`working` 和 `blocked`。对同一个生命周期权威,Herdr 不再使用屏幕清单兜底。 | -| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 | - -自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。 - -一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 +Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI 和 MastraCode 的集成还会上报智能体的状态。在它们上报期间,Herdr 使用这些上报,而不是读取屏幕。其他集成则把状态交给屏幕检测。 原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Qwen Code 版本 `1`、Letta Code 版本 `1`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `2`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 @@ -202,7 +195,7 @@ herdr integration install opencode 该集成支持 OpenCode V1 `1.18.29` 及更高版本,以及 OpenCode V2(已验证 beta `19242`)。OpenCode 配置目录必须已经存在。Herdr 将服务端入口安装到 `~/.config/opencode/plugins/herdr-agent-state.js`,并在该配置目录安装共享 TUI 插件 `herdr-tui-session.js` 和 V2 TUI 入口 `herdr-opencode/tui.js`。 -安装会在 `tui.jsonc` 中注册 V1 TUI 插件,并在 `cli.json` 中注册 V2 TUI 插件,同时保留其他设置和插件。如果 OpenCode 仍有 V1 TUI 偏好需要迁移(`tui.json` 或 `kv.json`),Herdr 会推迟注册,以便首次启动时执行迁移;此时请先启动一次 `opencode2`,然后重新安装集成。否则 Herdr 会创建 `cli.json` 并注册插件。安装后请重启 OpenCode TUI。卸载会删除受管理的插件文件及其配置条目。 +安装会在 `tui.jsonc` 中注册 V1 TUI 插件,并在 `cli.json` 中注册 V2 TUI 插件,同时保留其他设置和插件。`tui.json` 中已有的 V1 注册也视为已安装,会直接复用而不创建 `tui.jsonc`。卸载会从两个 TUI 配置文件中移除 Herdr 注册,同时保留文件和其他设置。如果 OpenCode 仍有 V1 TUI 偏好需要迁移(`tui.json` 或 `kv.json`),Herdr 会推迟注册,以便首次启动时执行迁移;此时请先启动一次 `opencode2`,然后重新安装集成。否则 Herdr 会创建 `cli.json` 并注册插件。安装后请重启 OpenCode TUI。卸载会删除受管理的插件文件及其配置条目。 V2 生命周期上报在窗格本地的 TUI 中运行。即使多个窗格共享同一个 OpenCode 服务端,事件也只归属于该 TUI 选中的根会话。完成和中断会清除工作状态;待处理的权限请求、表单以及执行失败会使窗格保持阻塞。V2 Mini 和无界面客户端不运行 TUI 插件,因此不提供此生命周期上报。 diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index e26c259e..de5c7e7e 100644 --- a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx +++ b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx @@ -482,6 +482,8 @@ Herdr 在本地 socket 上使用换行分隔的 JSON。在 Unix 上,那个 socke {"id":"req_1","result":{"type":"pong"}} ``` +对于已进入 JSON 解码的请求行,错误响应也会回显请求的 `id`,包括方法或参数无效的情况。如果 JSON 无效或顶层没有明确的字符串 `id`,错误响应会使用空 `id`。无效 UTF-8 等传输错误可能直接关闭连接而不返回响应。 + 事件订阅在初始响应之后保持连接打开。 ## Socket 路径 @@ -541,7 +543,7 @@ Herdr 在本地 socket 上使用换行分隔的 JSON。在 Unix 上,那个 socke } ``` -两个方法都接受可选的 `resume_argv` 数组,内容是 Herdr 服务器重启后恢复上报者当前会话的命令。第一个元素必须是普通命令名而不是路径,并且任何元素都不能包含撇号;无效值会以 `invalid_resume_argv` 失败,该次上报不会生效。自定义智能体必须先通过 `pane.report_agent` 持有窗格才能附加命令,否则请求会以 `resume_not_accepted` 失败。Herdr 只在同一 `source` 和 `agent` 持有窗格时保留该命令,并且它优先于 Herdr 为该智能体内置的恢复命令。旧版 Herdr 会忽略这个字段。 +两个方法都接受可选的 `resume_argv` 数组,内容是 Herdr 服务器重启后恢复上报者当前会话的命令。第一个元素必须是普通命令名而不是路径。该数组最多允许 64 个元素,总大小不超过 8 KiB,并且任何元素都不能包含撇号或控制字符;无效值会以 `invalid_resume_argv` 失败,该次上报不会生效。自定义智能体必须先通过 `pane.report_agent` 持有窗格才能附加命令,否则请求会以 `resume_not_accepted` 失败。Herdr 只在同一 `source` 和 `agent` 持有窗格时保留该命令,并且它优先于 Herdr 为该智能体内置的恢复命令。旧版 Herdr 会忽略这个字段。 ```json { @@ -638,7 +640,7 @@ workspace 的 get/list 响应会公开生成的 `tokens` 映射,空间侧边栏 } ``` -第一个响应确认订阅。之后的行是推送的事件。生命周期事件订阅从请求被接受时开始,不会重放在此之前保留的事件。 +第一个响应确认订阅。之后的行是推送的事件。所有条目都必须有效:如果引用的任何窗格不存在,Herdr 会拒绝整个请求,返回错误并关闭连接。请刷新窗格列表后重试,不要假定其他条目已经订阅。生命周期事件订阅从请求被接受时开始,不会重放在此之前保留的事件。 生命周期和智能体状态事件按有界批次发送,每个订阅内保持事件顺序。事件历史不是持久日志。如果订阅者落后到保留范围之外,包括订阅初始化期间,服务器会发送包含原请求 `id` 和 `error.code: "events_lost"` 的错误响应,然后关闭该订阅连接,而不是静默跳过缺失事件后继续发送。其他客户端的连接不受影响。各类事件共享历史缓冲,因此即使被淘汰的事件可能不匹配此订阅的过滤条件,仍会报告溢出。 From 48292af8e33a08c8030b7f1512c8d0da739f5ab1 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik <ogulcancelik@gmail.com> Date: Tue, 29 Sep 2026 16:10:47 +0300 Subject: [PATCH 93/99] release: v0.9.2 --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- docs/next/CHANGELOG.md | 2 ++ skills/herdr/SKILL.md | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85abb252..2f32e719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [0.9.2] - 2026-09-29 + ### 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) diff --git a/Cargo.lock b/Cargo.lock index 0ca58835..87d7ae6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -781,7 +781,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "herdr" -version = "0.9.1" +version = "0.9.2" dependencies = [ "base64", "bincode", diff --git a/Cargo.toml b/Cargo.toml index c1779763..cc787ef1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "herdr" -version = "0.9.1" +version = "0.9.2" edition = "2021" build = "build.rs" description = "terminal workspace manager for AI coding agents" diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 85abb252..2f32e719 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [0.9.2] - 2026-09-29 + ### 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) 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 [<label-or-id>] --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 From 8e78f929d8f0306a5c68518969e90274c44cb1f0 Mon Sep 17 00:00:00 2001 From: Can Celik <ogulcancelik@gmail.com> Date: Tue, 29 Sep 2026 20:34:45 +0300 Subject: [PATCH 94/99] fix: keep escape-prefixed terminal key bindings intact (#4759) * fix: keep escape-prefixed terminal key bindings intact refs #4751 * test: cover escape-prefixed option+backspace binding refs #4751 (cherry picked from commit 898f733f92dbbe53c428845534b03adb573f92be) --- src/client/input.rs | 278 +++++++++++++++++++++++++++++++++++++++----- src/raw_input.rs | 160 ++++++++++--------------- 2 files changed, 312 insertions(+), 126 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index ab46e788..08254155 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -143,8 +143,7 @@ fn unix_stdin_reader_loop( return; } if held_escape - && stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) - == Some(false) + && stdin_read_ready(&reader, framer.held_input_flush_timeout_ms()) == Some(false) && !send_unix_input_chunks( framer.flush_timeout(), &event_tx, @@ -253,10 +252,8 @@ fn unix_stdin_reader_loop( return; } if held_escape - && stdin_read_ready( - &reader, - crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS, - ) == Some(false) + && stdin_read_ready(&reader, framer.held_input_flush_timeout_ms()) + == Some(false) { let chunks = framer.flush_timeout(); if !framer.has_pending_input() { @@ -379,14 +376,14 @@ fn idle_flush_timeout_ms( framer: &crate::raw_input::RawInputByteFramer, host_mouse_capture_active: bool, ) -> i32 { - // A mouse report split after ESC[ is still ambiguous with legacy Alt+[. - // Give it the mouse-active grace period without changing timeout decoding. - if host_mouse_capture_active - && (framer.has_pending_lone_escape() - || framer.has_pending_csi_introducer() - || framer.has_pending_incomplete_mouse_sequence()) - { + if !host_mouse_capture_active { + return crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS; + } + if framer.has_pending_lone_escape() || framer.has_pending_incomplete_mouse_sequence() { crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS + } else if framer.has_pending_csi_introducer() { + // A mouse report split after ESC[ is still ambiguous with legacy Alt+[. + crate::raw_input::MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS } else { crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS } @@ -799,19 +796,16 @@ mod tests { assert_eq!(framer.flush_timeout().len(), 1); } - #[test] - fn captured_mouse_report_split_after_csi_survives_idle_gap() { - let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); - framer.enable_host_appearance_query_on_focus(); - assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); - assert!(framer.push(b"\x1b[").is_empty()); - - // #4630 input.bin offsets 10465/10467: ESC[ and its continuation - // arrived 32.937 ms apart. Apply the Unix reader's two idle waits - // without sleeping, using its production timeout selector. - let gap = std::time::Duration::from_micros(32_937); + /// Applies the Unix reader's idle waits for a gap between two host writes + /// without sleeping, using its production timeout selector. + fn reader_chunks_across_gap( + framer: &mut crate::raw_input::RawInputByteFramer, + mouse_capture: bool, + gap: std::time::Duration, + next: &[u8], + ) -> Vec<Vec<u8>> { let first_wait = - std::time::Duration::from_millis(idle_flush_timeout_ms(&framer, true) as u64); + std::time::Duration::from_millis(idle_flush_timeout_ms(framer, mouse_capture) as u64); let mut chunks = Vec::new(); if gap >= first_wait { chunks.extend(framer.flush_timeout()); @@ -819,13 +813,237 @@ mod tests { && gap >= first_wait + std::time::Duration::from_millis( - crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS as u64, + framer.held_input_flush_timeout_ms() as u64 ) { chunks.extend(framer.flush_timeout()); } } - chunks.extend(framer.push(b"<35;64;37M\x1b[<35;65;36M\x1b[<35;64;36M")); + chunks.extend(framer.push(next)); + chunks + } + + fn confirmed_disambiguation_framer() -> crate::raw_input::RawInputByteFramer { + let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + framer.set_host_escape_disambiguation_active(true); + framer + } + + #[test] + fn confirmed_disambiguation_keeps_mouse_report_split_by_delayed_tail() { + // #3480: the tail of a click arrived 350 ms after its ESC. + let gap = std::time::Duration::from_millis(350); + for (prefix, tail) in [ + (b"\x1b".as_slice(), b"[<0;5;5M".as_slice()), + (b"\x1b[", b"<0;5;5M"), + (b"\x1b[<0;", b"5;5M"), + ] { + let mut framer = confirmed_disambiguation_framer(); + assert!(framer.push(prefix).is_empty()); + + assert_eq!( + reader_chunks_across_gap(&mut framer, true, gap, tail), + vec![b"\x1b[<0;5;5M".to_vec()], + "prefix {prefix:?} must rejoin its mouse tail" + ); + } + } + + #[test] + fn confirmed_disambiguation_keeps_escape_prefixed_bindings_intact() { + // Ghostty's macOS Alt+Left/Right and Alacritty Shift+Enter bindings (#4751). + // iTerm2's Natural Text Editing preset also sends ESC DEL for Option+Backspace. + for binding in [b"\x1bb".as_slice(), b"\x1bf", b"\x1b\r", b"\x1b\x7f"] { + let mut framer = confirmed_disambiguation_framer(); + + assert_eq!(framer.push(binding), vec![binding.to_vec()]); + } + } + + #[test] + fn confirmed_disambiguation_releases_escape_prefixed_bindings_after_bounded_wait() { + // Terminal text bindings can send ESC, Alt+[ or Alt+O alone (#4751). + let gap = std::time::Duration::from_secs(1); + for binding in [b"\x1b".as_slice(), b"\x1b[", b"\x1bO"] { + let mut framer = confirmed_disambiguation_framer(); + assert!(framer.push(binding).is_empty()); + + assert_eq!( + reader_chunks_across_gap(&mut framer, true, gap, b"x"), + vec![binding.to_vec(), b"x".to_vec()], + "binding {binding:?} must not be held or dropped" + ); + } + } + + #[test] + fn confirmed_disambiguation_separates_escape_prefixed_binding_from_fast_next_key() { + // Typing right after an Alt+O / Alt+[ / ESC text binding must not glue + // the next key onto it while Herdr is watching for a delayed mouse tail. + for (binding, next, gap_ms) in [ + (b"\x1bO".as_slice(), b"q".as_slice(), 80), + (b"\x1b[", b"A", 80), + (b"\x1b", b"x", 200), + ] { + let mut framer = confirmed_disambiguation_framer(); + assert!(framer.push(binding).is_empty()); + + assert_eq!( + reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_millis(gap_ms), + next + ), + vec![binding.to_vec(), next.to_vec()], + "binding {binding:?} followed by {next:?} after {gap_ms} ms" + ); + } + } + + #[test] + fn confirmed_disambiguation_releases_binding_while_host_reply_is_pending() { + for (binding, next) in [(b"\x1b[".as_slice(), b"A".as_slice()), (b"\x1b", b"x")] { + let mut framer = confirmed_disambiguation_framer(); + framer.host_cell_size_query_sent(); + framer.host_color_query_sent(); + assert!(framer.push(binding).is_empty()); + + assert_eq!( + reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_secs(1), + next + ), + vec![binding.to_vec(), next.to_vec()], + "binding {binding:?} must not stay held behind a pending host reply" + ); + } + } + + #[test] + fn confirmed_disambiguation_keeps_slow_host_reply_and_paste_whole() { + let paste = b"200~hello\n\x1b[201~"; + for (tail, gap_ms) in [(b"6;21;10t".as_slice(), 55), (paste.as_slice(), 80)] { + let mut framer = confirmed_disambiguation_framer(); + framer.host_cell_size_query_sent(); + assert!(framer.push(b"\x1b[").is_empty()); + + let mut whole = b"\x1b[".to_vec(); + whole.extend_from_slice(tail); + assert_eq!( + reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_millis(gap_ms), + tail + ), + vec![whole], + "tail {tail:?} after {gap_ms} ms must stay attached to ESC[" + ); + } + } + + #[test] + fn confirmed_disambiguation_keeps_osc_reply_split_after_escape_whole() { + let mut framer = confirmed_disambiguation_framer(); + framer.host_color_query_sent(); + assert!(framer.push(b"\x1b").is_empty()); + + let tail = b"]11;rgb:1111/2222/3333\x07"; + let mut whole = b"\x1b".to_vec(); + whole.extend_from_slice(tail); + assert_eq!( + reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_millis(155), + tail + ), + vec![whole] + ); + } + + #[test] + fn confirmed_disambiguation_mouse_wait_does_not_consume_later_reply_hold() { + let mut framer = confirmed_disambiguation_framer(); + framer.enable_host_appearance_query_on_focus(); + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b[<0;").is_empty()); + + assert!(reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_millis(650), + b"\x1b[?997;" + ) + .is_empty()); + assert_eq!( + reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_millis(15), + b"2n" + ), + vec![b"\x1b[?997;2n".to_vec()] + ); + } + + #[test] + fn confirmed_disambiguation_keeps_kitty_escape_immediate() { + let mut framer = confirmed_disambiguation_framer(); + + assert_eq!(framer.push(b"\x1b[27u"), vec![b"\x1b[27u".to_vec()]); + } + + #[test] + fn confirmed_disambiguation_joins_escape_binding_split_before_wait_ends() { + let mut framer = confirmed_disambiguation_framer(); + assert!(framer.push(b"\x1b").is_empty()); + + assert_eq!( + reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_millis(40), + b"\r" + ), + vec![b"\x1b\r".to_vec()] + ); + } + + #[test] + fn legacy_alt_bracket_is_not_glued_to_following_key() { + let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + assert!(framer.push(b"\x1b[").is_empty()); + + assert_eq!( + reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_millis(80), + b"a" + ), + vec![b"\x1b[".to_vec(), b"a".to_vec()] + ); + } + + #[test] + fn captured_mouse_report_split_after_csi_survives_idle_gap() { + let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + framer.enable_host_appearance_query_on_focus(); + assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]); + assert!(framer.push(b"\x1b[").is_empty()); + + // #4630 input.bin offsets 10465/10467: ESC[ and its continuation + // arrived 32.937 ms apart. + let chunks = reader_chunks_across_gap( + &mut framer, + true, + std::time::Duration::from_micros(32_937), + b"<35;64;37M\x1b[<35;65;36M\x1b[<35;64;36M", + ); assert_eq!( chunks, vec![ @@ -858,12 +1076,16 @@ mod tests { crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS ); } - for framer in [&escape, &csi, &sgr_mouse, &default_mouse] { + for framer in [&escape, &sgr_mouse, &default_mouse] { assert_eq!( idle_flush_timeout_ms(framer, true), crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS ); } + assert_eq!( + idle_flush_timeout_ms(&csi, true), + crate::raw_input::MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS + ); assert_eq!( idle_flush_timeout_ms(&unrelated, true), crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS diff --git a/src/raw_input.rs b/src/raw_input.rs index 1af4e894..c66d7584 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -25,6 +25,12 @@ const ESC: u8 = 0x1b; pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10; #[cfg(unix)] pub(crate) const MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 150; +/// Covers the 33 ms split in #4630 without gluing legacy Alt+[ to the next key. +#[cfg(unix)] +pub(crate) const MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS: i32 = 50; +/// Covers the 350 ms mouse tail delay in #3480. Other input ends it early (#4751). +#[cfg(unix)] +const DISAMBIGUATED_MOUSE_TAIL_FLUSH_TIMEOUT_MS: i32 = 500; pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n"; pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n"; const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; @@ -192,7 +198,10 @@ pub(crate) struct RawInputByteFramer { host_color_scheme_change_tracking: bool, host_appearance_query_on_focus: bool, split_coalesced_escape: bool, + #[cfg(unix)] host_escape_disambiguation_active: bool, + #[cfg(unix)] + awaiting_mouse_tail_after: Option<usize>, } const HOST_COLOR_QUERY_REPLIES: u16 = 258; @@ -216,6 +225,15 @@ impl RawInputByteFramer { pub(crate) fn push(&mut self, data: &[u8]) -> Vec<Vec<u8>> { self.buffer.extend_from_slice(data); + #[cfg(unix)] + if let Some(prefix_len) = self.awaiting_mouse_tail_after.take() { + if !continues_escape_sequence(&self.buffer) { + // The prefix already outlived keyboard timing; it was a key. + let mut chunks = vec![self.buffer.drain(..prefix_len).collect()]; + chunks.extend(self.drain_available_chunks()); + return chunks; + } + } self.drain_available_chunks() } @@ -281,11 +299,21 @@ impl RawInputByteFramer { !self.buffer.is_empty() } - #[cfg(any(unix, test))] + #[cfg(unix)] pub(crate) fn set_host_escape_disambiguation_active(&mut self, active: bool) { self.host_escape_disambiguation_active = active; } + /// How long to keep holding input after a first idle flush held it. + #[cfg(unix)] + pub(crate) fn held_input_flush_timeout_ms(&self) -> i32 { + if self.awaiting_mouse_tail_after.is_some() { + DISAMBIGUATED_MOUSE_TAIL_FLUSH_TIMEOUT_MS + } else { + RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + } + } + #[cfg(unix)] pub(crate) fn has_pending_lone_escape(&self) -> bool { self.buffer.as_slice() == [ESC] @@ -354,13 +382,21 @@ impl RawInputByteFramer { return chunks; } - if self.host_escape_disambiguation_active - && starts_with_bounded_incomplete_escape_sequence(&self.buffer) + // A confirmed host sends Escape as `CSI 27u`, so a mouse report prefix + // that outlives keyboard timing may still get a delayed tail (#3480). + // Keep it briefly; `push` releases it if other input follows. + // A finished mouse wait also counts as this prefix's one-flush host + // reply hold, so the two holds never stack. + #[cfg(unix)] + let mouse_wait_served = self.awaiting_mouse_tail_after.take().is_some(); + #[cfg(not(unix))] + let mouse_wait_served = false; + #[cfg(unix)] + if !mouse_wait_served + && self.host_escape_disambiguation_active + && could_continue_as_mouse_report(&self.buffer) { - tracing::trace!( - len = self.buffer.len(), - "holding incomplete host escape sequence with disambiguation active" - ); + self.awaiting_mouse_tail_after = Some(self.buffer.len()); return chunks; } @@ -433,7 +469,7 @@ impl RawInputByteFramer { if (self.host_cell_size_replies_awaited > 0 || self.host_appearance_reply_awaited) && self.buffer.as_slice() == b"\x1b[" { - if !self.held_pending_host_reply_esc { + if !self.held_pending_host_reply_esc && !mouse_wait_served { self.held_pending_host_reply_esc = true; tracing::trace!("holding incomplete host CSI reply one flush"); return chunks; @@ -500,7 +536,8 @@ impl RawInputByteFramer { // Native console reply fragments can arrive across several idle polls. return chunks; } - if self.awaiting_host_reply() && !self.held_pending_host_reply_esc { + if self.awaiting_host_reply() && !self.held_pending_host_reply_esc && !mouse_wait_served + { self.held_pending_host_reply_esc = true; tracing::trace!("holding lone escape one flush while awaiting host reply"); return chunks; @@ -657,15 +694,6 @@ impl RawInputByteFramer { continue; } - if self.host_escape_disambiguation_active - && self.buffer.first() == Some(&ESC) - && self.buffer.len() > 1 - && !starts_with_known_escape_introducer(&self.buffer) - { - self.buffer.drain(..1); - continue; - } - let Some((event, consumed)) = extract_one_event(&self.buffer) else { break; }; @@ -1072,23 +1100,24 @@ fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool { .all(|byte| byte.is_ascii_digit() || *byte == b';') } -fn starts_with_known_escape_introducer(buffer: &[u8]) -> bool { - buffer - .get(1) - .is_some_and(|byte| matches!(*byte, b'[' | b'O' | b']' | b'P' | b'_' | b'^' | b'X' | ESC)) +/// Whether bytes after a held ESC or ESC[ still read as one escape sequence, +/// such as a mouse report, host reply, or bracketed paste, rather than a key. +#[cfg(unix)] +fn continues_escape_sequence(buffer: &[u8]) -> bool { + match buffer { + [ESC] | [ESC, b'['] => true, + // OSC, DCS and APC host replies can also split after their ESC. + [ESC, b']' | b'P' | b'_', ..] => true, + [ESC, b'[', next, ..] => *next == b'M' || matches!(*next, 0x20..=0x3f), + _ => false, + } } -fn starts_with_bounded_incomplete_escape_sequence(buffer: &[u8]) -> bool { - if buffer.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES { - return false; - } - if buffer == [ESC] || buffer == b"\x1bO" { - return true; - } - let Some(body) = buffer.strip_prefix(b"\x1b[") else { - return false; - }; - body.iter().all(|byte| matches!(*byte, 0x20..=0x3f)) +#[cfg(unix)] +fn could_continue_as_mouse_report(buffer: &[u8]) -> bool { + [b"\x1b[<".as_slice(), b"\x1b[M"] + .iter() + .any(|prefix| buffer.starts_with(prefix) || prefix.starts_with(buffer)) } #[cfg(any(unix, windows, test))] @@ -2276,71 +2305,6 @@ mod tests { assert!(framer.timed_out_mouse_prefix.is_none()); } - #[test] - fn confirmed_host_disambiguation_retains_split_sgr_mouse_without_escape() { - for (prefix, tail) in [ - (b"\x1b".as_slice(), b"[<0;5;5M".as_slice()), - (b"\x1b[".as_slice(), b"<0;5;5M".as_slice()), - (b"\x1b[<0;".as_slice(), b"5;5M".as_slice()), - ] { - let mut framer = RawInputFramer::default(); - framer - .byte_framer - .set_host_escape_disambiguation_active(true); - - assert!(framer.push(prefix).is_empty()); - assert!(framer.flush_timeout().is_empty()); - assert!(framer.flush_timeout().is_empty()); - let events = framer.push(tail); - - assert!(matches!( - events.as_slice(), - [RawInputEvent::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 4, - row: 4, - .. - })] - )); - } - } - - #[test] - fn confirmed_host_disambiguation_drops_stale_escape_before_plain_input() { - let mut framer = RawInputFramer::default(); - framer - .byte_framer - .set_host_escape_disambiguation_active(true); - - assert!(framer.push(b"\x1b").is_empty()); - assert!(framer.flush_timeout().is_empty()); - let events = framer.push(b"x"); - - assert_eq!(events.len(), 1); - assert_raw_key( - events.into_iter().next().unwrap(), - KeyCode::Char('x'), - KeyModifiers::empty(), - ); - } - - #[test] - fn confirmed_host_disambiguation_keeps_kitty_escape_immediate() { - let mut framer = RawInputFramer::default(); - framer - .byte_framer - .set_host_escape_disambiguation_active(true); - - let events = framer.push(b"\x1b[27u"); - - assert_eq!(events.len(), 1); - assert_raw_key( - events.into_iter().next().unwrap(), - KeyCode::Esc, - KeyModifiers::empty(), - ); - } - #[test] fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { let mut framer = RawInputFramer::default(); From 70497262b8019ecedb796a9634d57b2b240df542 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik <ogulcancelik@gmail.com> Date: Tue, 29 Sep 2026 21:59:32 +0300 Subject: [PATCH 95/99] docs: prepare 0.9.3 release notes --- CHANGELOG.md | 6 ++++++ docs/next/CHANGELOG.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f32e719..da0312b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +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) + ## [0.9.2] - 2026-09-29 ### Breaking Changes diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 2f32e719..da0312b8 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +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) + ## [0.9.2] - 2026-09-29 ### Breaking Changes From 7b116c05bfda646af39d2524c54e70c751f57ee8 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik <ogulcancelik@gmail.com> Date: Tue, 29 Sep 2026 22:06:04 +0300 Subject: [PATCH 96/99] release: v0.9.3 --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- docs/next/CHANGELOG.md | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da0312b8..d4d32870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [0.9.3] - 2026-09-29 + 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 diff --git a/Cargo.lock b/Cargo.lock index 87d7ae6c..fed0f874 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -781,7 +781,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "herdr" -version = "0.9.2" +version = "0.9.3" dependencies = [ "base64", "bincode", diff --git a/Cargo.toml b/Cargo.toml index cc787ef1..c6731424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "herdr" -version = "0.9.2" +version = "0.9.3" edition = "2021" build = "build.rs" description = "terminal workspace manager for AI coding agents" diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index da0312b8..d4d32870 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [0.9.3] - 2026-09-29 + 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 From 001153edb0ce010df07fe5b7e215c155040973a2 Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:08:31 -0700 Subject: [PATCH 97/99] chore(release): complete the herdr v0.9.3 sync --- Cargo.lock | 2 +- Cargo.toml | 3 +- src/build_info.rs | 2 +- src/persist/restore.rs | 3 + src/server/headless/tests/project_snooze.rs | 2 +- src/server/headless/tests/workspace_snooze.rs | 8 +-- src/terminal/state.rs | 61 +++++++++++++++++++ 7 files changed, 73 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a5e2fbb..28d96412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ dependencies = [ [[package]] name = "superherdr" -version = "0.9.1" +version = "0.9.3" dependencies = [ "base64", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 44b9b13e..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", diff --git a/src/build_info.rs b/src/build_info.rs index 5154788a..89af97f4 100644 --- a/src/build_info.rs +++ b/src/build_info.rs @@ -3,7 +3,7 @@ pub const BASE_VERSION: &str = env!("CARGO_PKG_VERSION"); // Superherdr releases are `<Herdr base>.<revision>`; reset to 1 when merging a new Herdr base. -pub const SUPERHERDR_REVISION: u32 = 2; +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/persist/restore.rs b/src/persist/restore.rs index 663f39df..71a48cbc 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -1977,6 +1977,9 @@ mod tests { history.workspaces[0].tabs[0] .panes .insert(saved_id, pane_history); + // The remapped layout changes the fingerprint upstream requires before + // trusting a history snapshot; restamp it for the mutated pair. + history.layout_fingerprint = super::super::snapshot::layout_fingerprint(&snapshot); let (events, _events_rx) = mpsc::channel(8); let (workspaces, terminals, runtimes) = restore( &snapshot, diff --git a/src/server/headless/tests/project_snooze.rs b/src/server/headless/tests/project_snooze.rs index 82987c8c..b243ed6b 100644 --- a/src/server/headless/tests/project_snooze.rs +++ b/src/server/headless/tests/project_snooze.rs @@ -307,7 +307,7 @@ async fn project_snooze_endpoint_hides_future_matching_workspace_and_preserves_u writer, }) ); - let _ = control_rx.recv().expect("initial snapshot"); + let _ = client_shell_snapshot(&control_rx); assert!(!server.handle_server_event(ServerEvent::ClientShellSnoozeSubscribe { client_id })); let _ = control_rx.recv().expect("initial snooze state"); diff --git a/src/server/headless/tests/workspace_snooze.rs b/src/server/headless/tests/workspace_snooze.rs index 37e8f928..c360e0f3 100644 --- a/src/server/headless/tests/workspace_snooze.rs +++ b/src/server/headless/tests/workspace_snooze.rs @@ -30,7 +30,7 @@ async fn snooze_subscribe_and_initial_state() { writer, }) ); - let _ = control_rx.recv().expect("initial snapshot"); + let _ = client_shell_snapshot(&control_rx); assert!(!server.handle_server_event(ServerEvent::ClientShellSnoozeSubscribe { client_id })); assert!(server.snooze_subscribers.contains(&client_id)); @@ -441,7 +441,7 @@ fn dispatch_focus_actions( request: (**request).clone(), respond_to, response_write_complete: None, - }, + }, ); let response = response_rx .recv_timeout(std::time::Duration::from_secs(1)) @@ -476,7 +476,7 @@ async fn workspace_snooze_rejects_noncanonical_ids_without_record() { surface_active: true, writer, }); - let _ = recv_server_message(&control_rx, "snapshot"); + let _ = client_shell_snapshot(&control_rx); let boot_id = server.client_shell_boot_id.clone(); for (request_id, workspace_id) in [("numeric", "1"), ("missing", "")] { let _ = server.handle_server_event(ServerEvent::ClientShellEndpointRequest { @@ -531,7 +531,7 @@ async fn stale_boot_and_revision_wake_are_rejected() { writer, }) ); - let _ = control_rx.recv().expect("snapshot"); + let _ = client_shell_snapshot(&control_rx); let boot_id = server.client_shell_boot_id.clone(); let workspace_id = server.app.public_workspace_id(0); let request = |id: &str, method| Request { diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 11d540b1..29141885 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -6160,6 +6160,67 @@ mod tests { assert!(terminal.persisted_agent_session.is_none()); } + #[test] + fn same_kind_process_replacement_does_not_retain_accepted_session_ref() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + let session_ref = + crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")).unwrap(); + let startup = terminal.set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + Some(session_ref.clone()), + Some(20), + Some("startup".into()), + ); + assert!( + startup.is_some(), + "startup session report should anchor the accepted session" + ); + let anchored = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(session_ref.clone()), + Some(21), + ); + assert!(anchored.is_some(), "accepted session should be anchored"); + assert_eq!( + terminal.current_session_identity_for_persistence(), + Some(( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRefKind::Path, + session_ref.value.clone(), + )) + ); + + let now = Instant::now(); + let release = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + false, + false, + true, + now, + ); + + assert!(release.session_ref_changed); + assert!(terminal + .current_session_identity_for_persistence() + .is_none()); + + let replacement = + terminal.set_detected_agent_process_at(Agent::Pi, now + Duration::from_millis(1)); + + assert!(!replacement.session_ref_changed); + assert!(terminal + .current_session_identity_for_persistence() + .is_none()); + } + #[test] fn process_exit_preserves_foreign_persisted_session_ref() { let mut terminal = test_terminal(); From 59becaf33569b7af528ac4daa94de4aa0b92b33c Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:08:31 -0700 Subject: [PATCH 98/99] feat(whats-new): show the full merged release history --- CHANGELOG.md | 133 +++++++++++++++++- CONTRIBUTING.md | 2 + README.md | 2 +- docs/next/focus-snooze-fork.md | 4 + .../proposal.md | 56 ++++++++ .../specs/superherdr-release-notes/spec.md | 45 ++++++ .../tasks.md | 21 +++ .../specs/superherdr-release-notes/spec.md | 53 +++++++ src/app/state.rs | 5 + src/client/shell/global_menu.rs | 29 ++-- src/client/shell/mobile.rs | 7 +- src/client/shell/overlay_input.rs | 33 +++-- src/client/shell/state.rs | 43 ++++-- src/client/shell/tests/chrome_context.rs | 17 +++ src/client/shell/tests/graphics.rs | 1 + src/client/shell/tests/mobile.rs | 24 +++- src/client/shell/tests/startup_overlays.rs | 127 +++++++++++++++-- src/release_notes.rs | 108 +++++++++++++- src/ui/release_notes.rs | 103 ++++++++++++++ 19 files changed, 739 insertions(+), 74 deletions(-) create mode 100644 openspec/changes/archive/2026-10-07-whats-new-full-history/proposal.md create mode 100644 openspec/changes/archive/2026-10-07-whats-new-full-history/specs/superherdr-release-notes/spec.md create mode 100644 openspec/changes/archive/2026-10-07-whats-new-full-history/tasks.md create mode 100644 openspec/specs/superherdr-release-notes/spec.md 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 <label-or-id>`. 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 <version>` 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/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 <host>` 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/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 <version>` 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/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 <version>` 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 <version>` 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 <version>`), 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 <version>` 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 <version>`), 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/src/app/state.rs b/src/app/state.rs index 65c06a6c..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)] diff --git a/src/client/shell/global_menu.rs b/src/client/shell/global_menu.rs index 8eeead2b..aca2a743 100644 --- a/src/client/shell/global_menu.rs +++ b/src/client/shell/global_menu.rs @@ -37,16 +37,16 @@ pub(super) fn global_menu_items( ClientGlobalMenuAction::Binding(crate::input::KeybindAction::ReloadConfig), ), ]; - if snapshot.update_available.is_some() || snapshot.latest_release_notes_available { - items.push(( - if snapshot.update_available.is_some() { - "update ready" - } else { - "what's new" - }, - ClientGlobalMenuAction::WhatsNew, - )); - } + // The history view is embedded in the binary, so the entry is always + // available regardless of update-check or stored-notes state. + items.push(( + if snapshot.update_available.is_some() { + "update ready" + } else { + "what's new" + }, + ClientGlobalMenuAction::WhatsNew, + )); items.push(( "detach", ClientGlobalMenuAction::Binding(crate::input::KeybindAction::Detach), @@ -117,15 +117,6 @@ impl ClientShellState { }) else { return; }; - if action == ClientGlobalMenuAction::WhatsNew - && self - .snapshot - .as_deref() - .and_then(|snapshot| snapshot.release_notes.as_ref()) - .is_none() - { - return; - } self.overlay = None; match action { ClientGlobalMenuAction::Binding(binding) => { diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 232a0693..f021330e 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -1101,13 +1101,12 @@ impl ClientShellState { ); } Some(ClientMobileTarget::Menu(index)) => { + // The What's New history view is always available, so every + // present menu entry is actionable. let actionable = self.snapshot.as_deref().is_some_and(|snapshot| { super::global_menu::global_menu_items(snapshot) .get(index) - .is_some_and(|(_, action)| { - *action != super::global_menu::ClientGlobalMenuAction::WhatsNew - || snapshot.release_notes.is_some() - }) + .is_some() }); if actionable { self.mobile_switcher_suspended = true; diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 2474053f..3072ce0c 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -59,14 +59,9 @@ impl ClientShellState { } pub(super) fn open_release_notes(&mut self) { - let Some(notes) = self - .snapshot - .as_deref() - .and_then(|snapshot| snapshot.release_notes.as_ref()) - else { - return; - }; - self.overlay = Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + // The menu action always opens the embedded merged history view, + // independent of the stored release-notes slot and update state. + self.overlay = Some(ClientShellOverlay::ReleaseNotes(full_history_notes_state())); self.chrome_drag = None; } @@ -89,15 +84,19 @@ impl ClientShellState { } else { ClientShellMode::Navigate }; - self.push_endpoint_method_with_kind( - crate::api::schema::Method::ReleaseNotesDismiss( - crate::api::schema::ReleaseNotesDismissParams { - version: notes.version.clone(), - }, - ), - PendingEndpointKind::ReleaseNotesDismiss, - outcome, - ); + // Only the stored-notes view dismisses server-side pending notes; + // the embedded history view has nothing to dismiss on the server. + if notes.from_stored_notes { + self.push_endpoint_method_with_kind( + crate::api::schema::Method::ReleaseNotesDismiss( + crate::api::schema::ReleaseNotesDismissParams { + version: notes.version.clone(), + }, + ), + PendingEndpointKind::ReleaseNotesDismiss, + outcome, + ); + } outcome.repaint = true; } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index f8ac9860..8342a5a4 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -1111,6 +1111,17 @@ pub(super) fn release_notes_state( body: notes.body.clone(), scroll: 0, preview: notes.preview, + from_stored_notes: true, + } +} + +pub(super) fn full_history_notes_state() -> crate::app::state::ReleaseNotesState { + crate::app::state::ReleaseNotesState { + version: crate::build_info::superherdr_version(), + body: crate::release_notes::full_history_body(), + scroll: 0, + preview: false, + from_stored_notes: false, } } @@ -2073,21 +2084,25 @@ impl ClientShellState { } } if let Some(ClientShellOverlay::ReleaseNotes(current)) = self.overlay.as_ref() { - match snapshot.release_notes.as_ref() { - Some(notes) - if current.version != notes.version - || current.body != notes.body - || current.preview != notes.preview => - { - self.overlay = - Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); - self.chrome_drag = None; - } - None => { - self.overlay = None; - self.chrome_drag = None; + // The embedded history view is independent of the stored + // release-notes slot; snapshots never close or replace it. + if current.from_stored_notes { + match snapshot.release_notes.as_ref() { + Some(notes) + if current.version != notes.version + || current.body != notes.body + || current.preview != notes.preview => + { + self.overlay = + Some(ClientShellOverlay::ReleaseNotes(release_notes_state(notes))); + self.chrome_drag = None; + } + None => { + self.overlay = None; + self.chrome_drag = None; + } + Some(_) => {} } - Some(_) => {} } } let previous_focused_workspace_id = self diff --git a/src/client/shell/tests/chrome_context.rs b/src/client/shell/tests/chrome_context.rs index 577c626a..bc121a94 100644 --- a/src/client/shell/tests/chrome_context.rs +++ b/src/client/shell/tests/chrome_context.rs @@ -464,9 +464,26 @@ fn global_menu_opens_from_sidebar_and_routes_client_actions() { assert!(help.actions.is_empty()); assert!(matches!(state.overlay, Some(ClientShellOverlay::Help(_)))); + // Index 3 is the always-available what's-new entry, which opens the + // embedded history view; detach follows it. state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { highlighted: 3, })); + let whats_new = state.handle_input_bytes(b"\r"); + assert!(whats_new.actions.is_empty()); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { + from_stored_notes: false, + .. + } + )) + )); + + state.overlay = Some(ClientShellOverlay::GlobalMenu(ClientGlobalMenuOverlay { + highlighted: 4, + })); let detach = state.handle_input_bytes(b"\r"); assert!(detach.detach); assert!(state.overlay.is_none()); diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs index e54301a4..c7b38bfa 100644 --- a/src/client/shell/tests/graphics.rs +++ b/src/client/shell/tests/graphics.rs @@ -248,6 +248,7 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { body: "body".into(), scroll: 0, preview: false, + from_stored_notes: true, }), ClientShellOverlay::Rename(ClientRenameOverlay { title: "rename", diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs index 4dac5490..bc0839ba 100644 --- a/src/client/shell/tests/mobile.rs +++ b/src/client/shell/tests/mobile.rs @@ -492,19 +492,19 @@ fn mobile_switcher_create_and_menu_rows_reuse_client_actions() { } #[test] -fn mobile_menu_keeps_inert_notes_open_and_cancel_without_workspace_in_navigate() { +fn mobile_menu_opens_history_view_without_notes_and_cancel_without_workspace_in_navigate() { let mut source_config = Config::default(); source_config.ui.prompt_new_workspace_name = true; let config = ClientShellConfig::from_config(&source_config); let mut projected = snapshot(); - projected.latest_release_notes_available = true; + projected.latest_release_notes_available = false; projected.release_notes = None; let mut state = ClientShellState::new(config); state.set_snapshot(Box::new(projected)); state.set_pane_surface(surface()); state.mode = ClientShellMode::Navigate; state.compose(44, 20).expect("mobile switcher"); - let inert_notes = state + let notes_row = state .hits .mobile_targets .iter() @@ -512,13 +512,23 @@ fn mobile_menu_keeps_inert_notes_open_and_cancel_without_workspace_in_navigate() .expect("what's new row"); state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), - column: inert_notes.x, - row: inert_notes.y, + column: notes_row.x, + row: notes_row.y, modifiers: KeyModifiers::empty(), })]); - assert_eq!(state.mode, ClientShellMode::Navigate); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes( + crate::app::state::ReleaseNotesState { + from_stored_notes: false, + .. + } + )) + )); + state.dismiss_release_notes(&mut ClientShellInput::default()); + // Dismissal returns to the focused workspace's terminal mode. + assert_eq!(state.mode, ClientShellMode::Terminal); assert!(state.overlay.is_none()); - assert!(!state.mobile_switcher_suspended); let mut empty = snapshot(); empty.focused_workspace_id = None; diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index aad131a1..741a9fcb 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -142,7 +142,16 @@ fn release_notes_reconcile_and_failed_dismiss_reopens_authoritative_snapshot() { preview: true, }); state.set_snapshot(Box::new(endpoint_snapshot.clone())); - state.open_release_notes(); + // The menu opens the embedded history view now, so open the stored-notes + // view directly to exercise its reconcile and dismiss plumbing. + let stored_notes = state + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.release_notes.clone()) + .expect("stored notes"); + state.overlay = Some(ClientShellOverlay::ReleaseNotes(release_notes_state( + &stored_notes, + ))); endpoint_snapshot.release_notes = Some(crate::protocol::ClientShellReleaseNotes { version: "0.8.4".into(), @@ -722,6 +731,12 @@ fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() state.config.palette.text ); state.activate_global_menu_item(3, &mut ClientShellInput::default()); + // Even with an update ready, the menu opens the embedded history view. + let Some(ClientShellOverlay::ReleaseNotes(history)) = state.overlay.as_ref() else { + panic!("history view did not open"); + }; + assert!(!history.from_stored_notes); + assert!(!history.preview); let notes = state.compose(106, 30).expect("release notes"); let bottom_row_start = usize::from(notes.width) * usize::from(notes.height - 1); let bottom_row = notes.cells[bottom_row_start..] @@ -739,9 +754,8 @@ fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() }) .collect::<Vec<_>>() .join("\n"); - assert!(text.contains("v0.8.3")); - assert!(text.contains("update ready")); - assert!(text.contains("detach, run herdr update")); + assert!(text.contains("v0.9.3.1")); + assert!(text.contains("SUPERHERDR 0.9.1.2")); assert!(!state.hits.release_notes_scrollbar.is_empty()); let outer = crate::ui::centered_popup_rect( Rect::new(0, 0, 106, 30), @@ -844,9 +858,25 @@ fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() crate::app::state::ReleaseNotesState { scroll: 3, .. } )) )); + // Closing the history view sends no endpoint request. let dismissed = state.handle_input_bytes(b"\r"); assert!(state.overlay.is_none()); assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(dismissed.actions.is_empty()); + + // The stored-notes view still dismisses by its stored version. + let stored = crate::protocol::ClientShellReleaseNotes { + version: "0.8.3".into(), + body: (0..40) + .map(|index| format!("- release line {index}")) + .collect::<Vec<_>>() + .join("\n"), + preview: true, + }; + state.overlay = Some(ClientShellOverlay::ReleaseNotes(release_notes_state( + &stored, + ))); + let dismissed = state.handle_input_bytes(b"\r"); assert!(matches!( &dismissed.actions[..], [ClientShellAction::Endpoint { request, .. }] @@ -955,6 +985,7 @@ fn coalesced_release_notes_open_and_mouse_uses_current_geometry() { body, scroll: 0, preview: true, + from_stored_notes: true, }; let metrics = crate::ui::release_notes_scroll_metrics( ¬es, @@ -1009,11 +1040,8 @@ fn coalesced_release_notes_open_and_mouse_uses_current_geometry() { }), ]); assert!(state.overlay.is_none()); - assert!(matches!( - &closed.actions[..], - [ClientShellAction::Endpoint { request, .. }] - if matches!(request.method, crate::api::schema::Method::ReleaseNotesDismiss(_)) - )); + // The menu opens the history view, whose close sends no endpoint request. + assert!(closed.actions.is_empty()); } #[test] @@ -1294,3 +1322,84 @@ fn client_settings_preview_restore_and_endpoint_integrations_are_owned_by_overla })) if integration_messages == &["installed codex"] )); } + +#[test] +fn whats_new_history_opens_without_notes_and_survives_snapshots_without_endpoint_requests() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + // No update, no stored notes, no availability flag: the entry must still + // be offered and the history view must still open. + let mut endpoint_snapshot = snapshot(); + endpoint_snapshot.latest_release_notes_available = false; + endpoint_snapshot.release_notes = None; + state.set_snapshot(Box::new(endpoint_snapshot)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("shell frame"); + state.toggle_global_menu(); + let menu = state.compose(106, 30).expect("menu without notes"); + let text = menu + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert!(text.contains("what's new")); + + state.activate_global_menu_item(3, &mut ClientShellInput::default()); + let Some(ClientShellOverlay::ReleaseNotes(history)) = state.overlay.as_ref() else { + panic!("history view did not open"); + }; + assert!(!history.from_stored_notes); + assert!( + history.body.starts_with("### Superherdr "), + "{:?}", + history.body + ); + assert!(history.body.contains("### Inherited from Herdr 0.9.3")); + assert!(!history.body.contains("Unreleased")); + let composed = state.compose(106, 30).expect("history frame"); + let composed_text = composed + .cells + .chunks(composed.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!( + composed_text.contains("SUPERHERDR 0.9.1.2"), + "{composed_text}" + ); + + // An empty snapshot (no notes, no update) must not close the view. + state.set_snapshot(Box::new(snapshot())); + state.compose(106, 30).expect("empty snapshot frame"); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ReleaseNotes(_)) + )); + + // A stale stored-note snapshot must not replace the history view. + let mut stale = snapshot(); + stale.latest_release_notes_available = true; + stale.release_notes = Some(crate::protocol::ClientShellReleaseNotes { + version: "0.8.0".into(), + body: "### Changed\n- Old body".into(), + preview: false, + }); + state.set_snapshot(Box::new(stale)); + state.compose(106, 30).expect("stale snapshot frame"); + let Some(ClientShellOverlay::ReleaseNotes(history)) = state.overlay.as_ref() else { + panic!("history view closed by a stale-note snapshot"); + }; + assert!(history.body.starts_with("### Superherdr ")); + + // Closing the history view sends no endpoint request. + let dismissed = state.handle_input_bytes(b"\r"); + assert!(state.overlay.is_none()); + assert!( + dismissed.actions.is_empty(), + "history close must not request anything: {:?}", + dismissed.actions + ); +} diff --git a/src/release_notes.rs b/src/release_notes.rs index b211e63d..f717a5e6 100644 --- a/src/release_notes.rs +++ b/src/release_notes.rs @@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize}; const PENDING_RELEASE_NOTES_PATH: &str = "release-notes.json"; +// Embedded at build time so the What's New history view works in installed +// builds with no checkout, no network, and no config-directory state. +pub const EMBEDDED_CHANGELOG: &str = include_str!("../CHANGELOG.md"); + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleaseNotes { pub version: String, @@ -150,7 +154,7 @@ fn extract_version_section(content: &str, version: &str) -> Option<String> { continue; } - if line.starts_with("## [") { + if line.starts_with("## [") || line.starts_with("### Inherited from Herdr") { break; } @@ -161,6 +165,36 @@ fn extract_version_section(content: &str, version: &str) -> Option<String> { (!body.is_empty()).then_some(body) } +/// The merged What's New document: released Superherdr sections first +/// (newest first, `Unreleased` omitted) followed by the inherited Herdr +/// sections. Version headings are restyled to `###` so the release-notes +/// renderer styles them, and inherited sections keep their `### Inherited +/// from Herdr <version>` headings. +pub fn full_history_body() -> String { + let mut lines = Vec::new(); + let mut started = false; + let mut skipping_unreleased = false; + + for line in EMBEDDED_CHANGELOG.lines() { + if let Some(rest) = line.strip_prefix("## [") { + started = true; + skipping_unreleased = rest.contains("Unreleased"); + if skipping_unreleased { + continue; + } + let version = rest.split(']').next().unwrap_or(rest).trim(); + lines.push(format!("### Superherdr {version}")); + continue; + } + if !started || skipping_unreleased { + continue; + } + lines.push(line.to_string()); + } + + normalize_body(&lines.join("\n")) +} + pub fn normalize_body(body: &str) -> String { body.lines() .map(str::trim_end) @@ -183,6 +217,78 @@ mod tests { ); } + #[test] + fn version_extraction_excludes_a_following_inherited_section() { + let changelog = "# Changelog\n\n## [0.2.3] - 2026-03-31\n\n### Changed\n- One\n\n### Inherited from Herdr 0.9.3\n\nFixed:\n- Upstream fix\n"; + let body = extract_version_section(changelog, "0.2.3").expect("section body"); + assert_eq!(body, "### Changed\n- One"); + assert!(!body.contains("Inherited")); + } + + #[test] + fn extraction_of_the_last_released_section_excludes_inherited_sections() { + let last_version = EMBEDDED_CHANGELOG + .lines() + .rfind(|line| line.starts_with("## [") && !line.contains("Unreleased")) + .and_then(|line| { + line.strip_prefix("## [") + .and_then(|rest| rest.split(']').next()) + }) + .map(str::to_string) + .expect("a released section"); + let body = extract_version_section(EMBEDDED_CHANGELOG, &last_version) + .expect("last released section"); + assert!( + body.contains("### Changed") + || body.contains("### Added") + || body.contains("### Fixed") + ); + assert!(!body.contains("Inherited from Herdr")); + } + + #[test] + fn full_history_starts_at_the_newest_released_superherdr_section() { + let body = full_history_body(); + assert!(!body.contains("Unreleased"), "{body}"); + assert!(body.starts_with("### Superherdr "), "{body}"); + } + + #[test] + fn full_history_lists_superherdr_sections_before_inherited_sections() { + let body = full_history_body(); + let first_inherited = body + .find("### Inherited from Herdr") + .expect("inherited sections"); + let last_superherdr = body.rfind("### Superherdr ").expect("superherdr sections"); + assert!( + last_superherdr < first_inherited, + "superherdr sections must come before inherited sections" + ); + } + + #[test] + fn full_history_lists_inherited_sections_newest_first() { + let body = full_history_body(); + let expected: Vec<String> = EMBEDDED_CHANGELOG + .lines() + .filter_map(|line| line.strip_prefix("### Inherited from Herdr ")) + .map(str::trim) + .map(str::to_string) + .collect(); + assert!(!expected.is_empty(), "changelog has inherited sections"); + let mut last_index = None; + for version in &expected { + let marker = format!("### Inherited from Herdr {version}"); + let index = body.find(&marker).unwrap_or_else(|| { + panic!("history body is missing the inherited section for {version}") + }); + if let Some(previous) = last_index { + assert!(index > previous, "inherited sections must be newest first"); + } + last_index = Some(index); + } + } + #[test] fn preserves_headings() { assert_eq!( diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index e54c4369..c6cb6858 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -324,6 +324,7 @@ mod tests { body: "### Added\n- One".into(), scroll: 0, preview: true, + from_stored_notes: true, }; let lines = release_notes_display_lines(¬es, "herdr update", &palette); @@ -368,3 +369,105 @@ mod tests { assert_eq!(line_text(&lines[2].1), "▏ second"); } } + +#[cfg(test)] +mod history_tests { + use super::*; + use crate::release_notes::{full_history_body, EMBEDDED_CHANGELOG}; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>() + } + + fn history_line_texts() -> Vec<String> { + let palette = Palette::catppuccin(); + let body = full_history_body(); + release_notes_lines(&body, &palette) + .iter() + .map(|(_, line)| line_text(line)) + .collect() + } + + #[test] + fn history_renders_released_superherdr_sections_before_inherited_sections() { + let texts = history_line_texts(); + let first_inherited = texts + .iter() + .position(|text| text.contains("INHERITED FROM HERDR")) + .expect("inherited sections render"); + let last_superherdr = texts + .iter() + .rposition(|text| text.contains("SUPERHERDR ")) + .expect("superherdr sections render"); + assert!(last_superherdr < first_inherited); + } + + #[test] + fn history_styles_inherited_sections_as_accented_headings() { + let palette = Palette::catppuccin(); + let newest_inherited = EMBEDDED_CHANGELOG + .lines() + .filter_map(|line| line.strip_prefix("### Inherited from Herdr ")) + .next() + .expect("changelog has inherited sections") + .trim(); + let body = full_history_body(); + let lines = release_notes_lines(&body, &palette); + let marker = format!(" INHERITED FROM HERDR {newest_inherited}"); + let (_, line) = lines + .iter() + .find(|(_, line)| line_text(line) == marker) + .unwrap_or_else(|| panic!("missing styled heading {marker}")); + assert_eq!(line.spans.len(), 2); + assert_eq!(line.spans[1].style.fg, Some(palette.accent)); + assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn history_omits_unreleased_sections() { + let unreleased_versions: Vec<String> = EMBEDDED_CHANGELOG + .lines() + .filter_map(|line| { + let rest = line.strip_prefix("## [")?; + rest.contains("Unreleased") + .then(|| rest.split(']').next().unwrap_or(rest).trim().to_string()) + }) + .collect(); + assert!( + !unreleased_versions.is_empty(), + "changelog currently has an unreleased section" + ); + let texts = history_line_texts(); + for version in unreleased_versions { + let marker = format!("SUPERHERDR {version}"); + assert!( + texts.iter().all(|text| !text.contains(&marker)), + "unreleased section {version} leaked into the view" + ); + } + } + + #[test] + fn history_never_renders_unsupported_markdown_markers() { + for text in history_line_texts() { + for marker in ["####", "## [", "](http", "**"] { + assert!( + !text.contains(marker), + "rendered line shows literal markdown {marker:?}: {text:?}" + ); + } + } + } + + #[test] + fn history_bullet_lines_render_as_bullets() { + let bullets = history_line_texts() + .iter() + .filter(|text| text.starts_with(" \u{2022} ")) + .count(); + assert!(bullets > 10, "expected many bullets, found {bullets}"); + } +} From 545c63654d145a1b6a8bc3bef3b499620e537234 Mon Sep 17 00:00:00 2001 From: jsonMartin <4033621+jsonMartin@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:26:04 -0700 Subject: [PATCH 99/99] style: format the headless snooze test module list --- src/server/headless/tests/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 6f9a7bf0..7d613a04 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -12,10 +12,10 @@ mod retained_graphics_tests; mod surface_delta_tests; #[path = "surface_interest.rs"] mod surface_interest_tests; -#[path = "workspace_snooze.rs"] -mod workspace_snooze_tests; #[path = "surface_scroll.rs"] mod surface_scroll_tests; +#[path = "workspace_snooze.rs"] +mod workspace_snooze_tests; fn client_shell_projection( receiver: &std::sync::mpsc::Receiver<Vec<u8>>,