diff options
64 files changed, 9066 insertions, 6654 deletions
@@ -22,7 +22,7 @@ include = [ base64 = "0.22.1" bincode = { version = "2", features = ["serde"] } bytes = "1" -clap = { version = "4.5", default-features = false, features = ["std"] } +clap = { version = "4.5", default-features = false, features = ["std", "help", "usage"] } clap_complete = "4.5" crossterm = "0.29" ctrlc = "3" diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index b3d57550..f9805cda 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,10 +3,13 @@ ## Unreleased ### Added -- Added named-agent `start`, `prompt`, and completion-wait workflows. Agent startup now targets an existing pane without changing topology, validates the requested interactive agent kind, and accepts optional native arguments after `--`. +- Added a live-agent CLI facade with named `start`, atomic `prompt`, logical `send-keys`, and server-owned `wait` workflows. Agent startup targets an existing pane without changing topology, validates the requested interactive agent kind and strict agent name, and accepts native arguments after `--`. - Added `ui.sidebar_start_collapsed` to launch Herdr with the sidebar collapsed. (#1463) - Added macOS support for the `HERDR_AGENT=<agent>` foreground-process hint, allowing agents hidden behind host-visible wrappers such as `nono` to use the named agent's screen manifest. (#679) +### Changed +- Agent commands now accept only a unique live agent name or the pane ID currently hosting that agent. Names are cleared when the occupant exits, is released, or is replaced. The old top-level `wait` commands were replaced by `agent wait` and `pane wait-output`, and `agent send` was replaced by `agent send-keys`. + ### Fixed - `herdr config check` now reports unknown config keys with their full paths instead of treating ignored typos as valid configuration. (#1573) - Codex panes with customized static terminal titles now fall back to the live working footer instead of remaining idle, while OSC activity remains preferred. (#1563) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index cb0c0b72..b066574f 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -597,9 +597,22 @@ "null" ] }, + "final_status": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/AgentStatus" + }, + { + "type": "null" + } + ] + }, "pane_id": { "type": "string" }, + "released": { + "type": "boolean" + }, "type": { "const": "pane_agent_detected", "type": "string" @@ -1183,6 +1196,16 @@ }, "text": { "type": "string" + }, + "wait": { + "anyOf": [ + { + "$ref": "#/schemas/request/$defs/AgentPromptWaitOptions" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1191,6 +1214,25 @@ ], "type": "object" }, + "AgentPromptWaitOptions": { + "properties": { + "timeout_ms": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "until": { + "items": { + "$ref": "#/schemas/request/$defs/AgentStatus" + }, + "type": "array" + } + }, + "type": "object" + }, "AgentReadParams": { "properties": { "format": { @@ -1239,18 +1281,21 @@ ], "type": "object" }, - "AgentSendParams": { + "AgentSendKeysParams": { "properties": { - "target": { - "type": "string" + "keys": { + "items": { + "type": "string" + }, + "type": "array" }, - "text": { + "target": { "type": "string" } }, "required": [ "target", - "text" + "keys" ], "type": "object" }, @@ -1309,6 +1354,31 @@ ], "type": "object" }, + "AgentWaitParams": { + "properties": { + "target": { + "type": "string" + }, + "timeout_ms": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "until": { + "items": { + "$ref": "#/schemas/request/$defs/AgentStatus" + }, + "type": "array" + } + }, + "required": [ + "target" + ], + "type": "object" + }, "ClientWindowTitleSetParams": { "properties": { "title": { @@ -4499,11 +4569,11 @@ { "properties": { "method": { - "const": "agent.send", + "const": "agent.send_keys", "type": "string" }, "params": { - "$ref": "#/schemas/request/$defs/AgentSendParams" + "$ref": "#/schemas/request/$defs/AgentSendKeysParams" } }, "required": [ @@ -4579,6 +4649,22 @@ { "properties": { "method": { + "const": "agent.wait", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/AgentWaitParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { "const": "pane.split", "type": "string" }, @@ -6332,9 +6418,22 @@ "null" ] }, + "final_status": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/AgentStatus" + }, + { + "type": "null" + } + ] + }, "pane_id": { "type": "string" }, + "released": { + "type": "boolean" + }, "type": { "const": "pane_agent_detected", "type": "string" @@ -8320,11 +8419,6 @@ "agent": { "$ref": "#/schemas/success_response/$defs/AgentInfo" }, - "baseline_state_change_seq": { - "format": "uint64", - "minimum": 0, - "type": "integer" - }, "type": { "const": "agent_prompted", "type": "string" @@ -8332,8 +8426,7 @@ }, "required": [ "type", - "agent", - "baseline_state_change_seq" + "agent" ], "type": "object" }, diff --git a/docs/next/website/src/content/docs/agent-automation.mdx b/docs/next/website/src/content/docs/agent-automation.mdx new file mode 100644 index 00000000..cbedd8c7 --- /dev/null +++ b/docs/next/website/src/content/docs/agent-automation.mdx @@ -0,0 +1,122 @@ +--- +title: Agent automation +description: Use Herdr's layout, pane, and agent primitives to coordinate coding agents from scripts or other agents. +--- + +Herdr can act 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. The important part is choosing 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`, and `maki`. Arguments after `--` are passed unchanged to that executable. + +`agent start` returns only after Herdr detects the expected agent in the same terminal and marks it ready for interactive input. 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 +``` + +The equivalent fixed-ID example is: + +```bash +herdr pane split --current --direction right --no-focus +herdr agent start reviewer --kind codex --pane w1:p2 -- -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. 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` submits immediately, then waits for the first matching lifecycle state observed afterward. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. To run prompts as separate turns, send the next prompt only after the previous wait returns. To steer an active turn with several messages, send those prompts without `--wait`, then use `agent wait` when you want to wait for the next settled state. 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` means the agent is ready for input and its tab has been seen in the focused Herdr UI. `done` is the same underlying idle state after background work finishes, until that tab is focused or `pane focus` / `agent focus` targets it. Reading through the CLI does not mark it seen. `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. + +`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. `--lines` limits the searched snapshot, `--regex` uses Rust regular-expression syntax and matches one line at a time, and `--raw` keeps ANSI escape sequences while matching. + +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 every read source, `--lines N` returns the last N logical lines. The socket API returns the text at `.result.read.text`. + +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/next/website/src/content/docs/agent-skill.mdx b/docs/next/website/src/content/docs/agent-skill.mdx index 13a90154..498861f7 100644 --- a/docs/next/website/src/content/docs/agent-skill.mdx +++ b/docs/next/website/src/content/docs/agent-skill.mdx @@ -58,7 +58,7 @@ This prevents an agent outside Herdr from trying to control a session it does no ## Agent-facing reference -The full command guide lives in the skill file itself. It covers pane IDs, `pane split`, `pane run`, `pane read`, `wait output`, `wait agent-status`, workspace and tab commands, and coordination recipes. +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: diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index 958852fa..ee275d19 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -5,6 +5,8 @@ description: See what Herdr can detect, how agent state works, and how integrati 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 important difference is not whether Herdr can see an agent. It is which signal is allowed to author `idle`, `working`, and `blocked`. @@ -110,7 +112,7 @@ herdr agent rename w1:p1 reviewer herdr agent rename reviewer --clear ``` -Targets accept terminal IDs, unique agent names, detected or reported agent labels, and legacy pane IDs. +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 @@ -131,38 +133,6 @@ herdr pane report-metadata w1:p1 \ 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. -## Start agents from the CLI - -Use workspace, tab, pane, and layout commands to create topology first. Then activate an existing shell pane as a supported interactive agent with a unique name: - -```bash -herdr pane split --current --direction right --no-focus -herdr agent start reviewer --kind pi --pane w1:p2 -``` - -`agent start` never creates or splits topology. Herdr chooses the canonical executable for the requested kind, labels the target pane with the unique name, and returns after the expected interactive agent is detected as idle, done, or blocked. Optional native arguments follow `--`: - -```bash -herdr agent start reviewer --kind pi --pane w1:p2 -- --name scratch --no-session -``` - -Submit a prompt by name. `--wait` binds the wait to that prompt and returns when the agent finishes or becomes blocked: - -```bash -herdr agent prompt reviewer "Review the current diff" --wait --timeout 120000 -``` - -`agent prompt` honors the terminal's live bracketed-paste mode before sending Enter, so text such as `A != B` reaches agents without triggering input-mode keybindings. Use it for submissions; `agent send` remains a low-level literal-text operation and does not submit a turn. - -For an agent that is already working, `agent wait` returns when its status becomes idle, done, or blocked. It returns immediately if one of those statuses is already current. - -```bash -herdr agent wait reviewer --timeout 120000 -herdr agent read reviewer --source recent-unwrapped --lines 120 -``` - -Use `herdr pane ...` commands for ordinary terminals, servers, tests, shells, topology, and low-level terminal input. - ## Attach directly to an agent Attach your current terminal to one agent terminal instead of the full Herdr UI: diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 4d668df9..88585b8a 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -130,6 +130,8 @@ Create a workspace without stealing focus: herdr workspace create --cwd ~/project --label api --no-focus ``` +A workspace is the 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 @@ -154,6 +156,10 @@ 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`. + +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 @@ -181,15 +187,19 @@ 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. +The split response exposes the new pane ID as `.result.pane.pane_id`. +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] +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 every source, `--lines N` returns the last N logical lines. `agent read` uses the same output and line behavior. + Send input: ```bash @@ -204,6 +214,7 @@ special keys such as `enter`, `tab`, `esc`, `backspace`, `left`, `right`, 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. @@ -218,8 +229,23 @@ herdr pane report-agent <pane_id> \ [--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 existing `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior. @@ -253,26 +279,34 @@ Metadata text is normalized before storage. Herdr trims surrounding whitespace, ## 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 <target> <text> -herdr agent prompt <name> <text> [--wait] [--timeout MS] +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 <name> [--timeout MS] +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 can be terminal IDs, unique agent names, detected or reported agent labels, or legacy pane IDs. Names and labels are agent identities. Terminal IDs and legacy pane IDs are low-level escape hatches. +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`, and `maki`. 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; topology must be created separately. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. `agent prompt` requires a unique active agent name, honors live bracketed-paste mode, and submits text plus Enter atomically. With `--wait`, it waits for a causally later idle, done, or blocked state. Standalone `agent wait` returns immediately for idle, done, or blocked and otherwise waits while the agent is working. +Start returns only after the expected agent owns the same terminal and is ready for interactive input. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. -`agent read` reads the resolved terminal stream. `agent send` writes literal text without submitting a turn; use `agent prompt` when the text must be protected from input-mode keybindings and followed by Enter. `agent get`, `agent focus`, and `agent attach` accept the broader agent target forms. `agent rename` can assign an identity to an automatically detected agent. +`agent prompt` honors live bracketed-paste mode and submits text plus encoded Enter atomically, including while the agent is working. With `--wait`, it waits for the first matching lifecycle state observed after submission; it does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. Send the next prompt only after the previous wait returns when you need separate turns. Send steering messages without `--wait`, then use `agent wait` when you want the next settled state. `--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. Repeat `--until STATUS` to accept more than one exact state. + +`idle` means ready for input after its tab has been seen in the focused Herdr UI; `done` is the same underlying idle state after unseen background work completes. Focusing that tab or targeting it with `pane focus` / `agent focus` marks it seen; reading it through the CLI does not. `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. @@ -302,21 +336,19 @@ the server closes the stream. Multiple observers can watch the same terminal without taking input, resize, scroll, or takeover authority. `terminal title clear` restores Herdr's default outer terminal window title. -## Waits +## Output waits Wait for output in a pane: ```bash -herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw] +herdr pane wait-output <pane_id> (--match <text> | --regex <pattern>) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw] ``` -Wait for pane agent state: +Use `pane wait-output` for normal commands and servers. Use `agent wait` for coding agents. -```bash -herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS] -``` +`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 with no line limit. `--match` finds a literal substring on one line; `--regex` uses Rust regular-expression syntax and also matches one line at a time. `--raw` keeps ANSI escape sequences instead of stripping them. -Use `wait output` for normal commands and servers. Use `wait agent-status` for coding agents. +`pane wait-output`, `agent wait`, and `agent prompt --wait` wait indefinitely when `--timeout` is omitted. A timeout or server error is emitted as JSON on stderr with exit status 1. CLI usage errors exit with status 2. ## Integrations @@ -434,6 +466,8 @@ in pane or agent APIs. Native non-terminal plugin panes are a later surface. | `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 | diff --git a/docs/next/website/src/content/docs/ja/agent-automation.mdx b/docs/next/website/src/content/docs/ja/agent-automation.mdx new file mode 100644 index 00000000..cd335fb6 --- /dev/null +++ b/docs/next/website/src/content/docs/ja/agent-automation.mdx @@ -0,0 +1,122 @@ +--- +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`、`maki` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 + +`agent start` は、同じターミナルで期待したエージェントを検出し、対話入力の準備ができたと Herdr が判断してから返ります。起動待機はデフォルトで 30 秒です。`--timeout` は 3000 より大きく 300000 以下のミリ秒で指定します。 + +```bash +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +``` + +固定 ID を使う同等の例: + +```bash +herdr pane split --current --direction right --no-focus +herdr agent start reviewer --kind codex --pane w1:p2 -- -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 中のエージェントにも送信できます。`esc`、`up`、`enter`、`ctrl+c` のような操作には `agent send-keys` を使います。`escape` も `esc` のエイリアスとして使えます。意図的に生のターミナルを操作したい場合は、ペイン入力コマンドを使ってください。 + +ペイン入力は、現在のプロセスに関係なくターミナルを指定します。エージェント入力はライブエージェントを解決し、そのエージェントがペインを制御しなくなっていれば操作を拒否します。 + +`agent prompt --wait` はプロンプトをすぐ送信し、その後に最初に観測された一致ライフサイクル状態を待ちます。個々のターンは追跡しません。エージェントがすでに working の場合、進行中ターンの完了が待機を満たすことがあります。別々のターンとして実行するには、前の待機が返ってから次のプロンプトを送信します。進行中ターンを複数のメッセージで操作する場合は `--wait` なしで送信し、次の安定状態を待つときに `agent wait` を使います。単独の `agent wait` は現在のエージェントを監視し、すでに状態が一致していれば即座に返ります。どちらもデフォルトでは `idle`、`done`、`blocked` を待ちます。複数の正確な状態を許可するには、`--until idle --until done` のように `--until` を繰り返します。`unknown` が必要な場合は `--until unknown` を明示してください。`agent prompt` では `--until` に `--wait` が必要です。 + +`idle` は、エージェントが入力待ちで、そのタブがフォーカス中の Herdr UI ですでに表示済みであることを示します。`done` は同じ基礎的な idle 状態ですが、バックグラウンド作業が完了してから、そのタブをフォーカスするか `pane focus` / `agent focus` で対象にするまで使われます。CLI で読み取るだけでは表示済みになりません。`blocked` は承認または質問 UI を Herdr が認識した状態です。`unknown` はエージェントが存在するもののライフサイクルを確実に分類できない状態で、成功完了を意味しません。違いが重要なら正確な `--until` を指定してください。 + +`pane wait-output` はエージェントのライフサイクルを解釈しません。選択したターミナルスナップショットをポーリングし、最初にすぐ検索するため、すでに存在する文字列も一致します。デフォルトのソース名は `recent` で、マッチング時は recent の折り返し前の出力として扱われます。`--lines` は検索範囲を制限し、`--regex` は Rust の正規表現構文で 1 行ずつ一致し、`--raw` は ANSI エスケープシーケンスを残して検索します。 + +CLI では、`pane read` と `agent read` のどちらもターミナルテキストを直接出力します。デフォルトは ANSI エスケープを除去した UTF-8 テキストです。ソースに装飾が含まれる場合、`--format ansi` または `--ansi` でエスケープを残します。`detection` ソースは常にプレーンテキストです。すべての read ソースで、`--lines N` は末尾の N 論理行を返します。socket API ではテキストを `.result.read.text` に返します。 + +成功した `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/next/website/src/content/docs/ja/agent-skill.mdx b/docs/next/website/src/content/docs/ja/agent-skill.mdx index 1fc6405d..97d109f7 100644 --- a/docs/next/website/src/content/docs/ja/agent-skill.mdx +++ b/docs/next/website/src/content/docs/ja/agent-skill.mdx @@ -58,7 +58,7 @@ claude ## エージェント向けリファレンス -コマンドの完全なガイドはスキルファイル自体にあります。ペイン ID、`pane split`、`pane run`、`pane read`、`wait output`、`wait agent-status`、ワークスペースとタブのコマンド、協調動作のレシピを扱っています。 +コマンドの完全なガイドはスキルファイル自体にあります。ペイン ID、`pane split`、`pane run`、`pane read`、`pane wait-output`、`agent wait`、ワークスペースとタブのコマンド、協調動作のレシピを扱っています。 ソースファイルはこちら: diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 14f61d03..4b08da69 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -5,6 +5,8 @@ description: Herdr が何を検出できるか、エージェント状態の仕ç Herdr は複数のコーディングエージェントを同時に動かすために作られています。各エージェントは、シェル、ログ、プロンプト、実行中プロセスをそのまま保った実際のターミナルペインの中にいます。Herdr はどのペインにエージェントがいるかを追跡し、その状態をタブとワークスペースに集約し、すべてのターミナルを手作業で見回る代わりに、注意が必要なペインへ直接ジャンプできるようにします。 +スクリプトや別のエージェントから連携させる方法は、[エージェント自動化](/ja/docs/agent-automation/) を参照してください。 + ## 対応エージェント 一般的なコーディングエージェントは、追加設定なしで自動検出されます。重要な違いは Herdr がエージェントを見えるかどうかではありません。どのシグナルが `idle`、`working`、`blocked` を決定する権限を持つかです。 @@ -110,7 +112,7 @@ herdr agent rename w1:p1 reviewer herdr agent rename reviewer --clear ``` -ターゲットにはターミナル ID、一意なエージェント名、検出または報告されたエージェントラベル、レガシーなペイン ID が使えます。 +ターゲットには一意なライブエージェント名、または現在そのエージェントをホストしているペイン ID を使います。ターミナル ID とエージェント kind のラベルだけでは指定できません。 ## カスタムステータスラベル @@ -131,38 +133,6 @@ herdr pane report-metadata w1:p1 \ エージェントサイドバー行では、`terminal_title` または `terminal_title_stripped` も任意で使えます。どちらもデフォルト行には含まれません。前者は安全性のために正規化された最新の OSC 0/2 ターミナルタイトルを表示し、後者は先頭にある認識済みのアクティビティまたはスピナーのグリフ 1 つと、それに続く空白を除去します。これらの値は Herdr サーバーが所有し、コールドリスタートをまたいで保持されません。また、メタデータのタイトルや意味的なエージェント状態から独立しています。そのため、除去後のテキストが同じなら、スピナーのアニメーションで生のタイトルが変わってもペイン更新は発行されません。 -## CLI からエージェントを起動する - -まずワークスペース、タブ、ペイン、レイアウトの各コマンドでトポロジーを作成します。次に、既存のシェルペインを一意な名前を持つ対応済み対話型エージェントとして起動します: - -```bash -herdr pane split --current --direction right --no-focus -herdr agent start reviewer --kind pi --pane w1:p2 -``` - -`agent start` はトポロジーを作成も分割もしません。要求された kind に対応する標準実行ファイルを Herdr が選び、対象ペインに一意な名前を付け、対話型エージェントが idle、done、または blocked として検出されるまで待ちます。ネイティブ引数は `--` の後に指定できます: - -```bash -herdr agent start reviewer --kind pi --pane w1:p2 -- --name scratch --no-session -``` - -名前でプロンプトを送信します。`--wait` はそのプロンプトに待機を結び付け、エージェントが完了または blocked になると返ります: - -```bash -herdr agent prompt reviewer "Review the current diff" --wait --timeout 120000 -``` - -`agent prompt` は Enter を送る前にターミナルの現在の bracketed paste モードを尊重するため、`A != B` のようなテキストも入力モードのキーバインドを発火させずにエージェントへ届きます。送信にはこれを使ってください。`agent send` は低レベルのリテラルテキスト操作のままで、ターンを送信しません。 - -すでに working のエージェントには `agent wait` を使います。状態が idle、done、または blocked になると返り、すでにその状態なら即座に返ります。 - -```bash -herdr agent wait reviewer --timeout 120000 -herdr agent read reviewer --source recent-unwrapped --lines 120 -``` - -通常のターミナル、サーバー、テスト、シェル、トポロジー、低レベルなターミナル入力には `herdr pane ...` コマンドを使ってください。 - ## エージェントに直接アタッチする 完全な Herdr UI ではなく、ひとつのエージェントターミナルに現在のターミナルをアタッチします: 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 dfa82472..7edfab23 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -126,6 +126,8 @@ herdr workspace close <workspace_id> 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 @@ -150,6 +152,10 @@ 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 @@ -173,14 +179,20 @@ 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` にあります。 + +`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] +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` ソースは常にプレーンテキストです。すべてのソースで `--lines N` は末尾の N 論理行を返します。`agent read` も同じ出力と行数の動作です。 + 入力を送る: ```bash @@ -191,6 +203,8 @@ 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` よりこちらを使ってください。個別の送信操作は低レベルのままで、コマンドを送信しません。 カスタムフックからエージェント状態を報告する: @@ -204,8 +218,23 @@ herdr pane report-agent <pane_id> \ [--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 のままです。 @@ -237,26 +266,34 @@ herdr pane report-metadata <pane_id> \ ## エージェント +ペインとエージェントの使い分けや一連の連携例は、[エージェント自動化](/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 <target> <text> -herdr agent prompt <name> <text> [--wait] [--timeout MS] +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 <name> [--timeout MS] +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 が使えます。名前とラベルはエージェントのアイデンティティです。ターミナル ID とレガシーペイン ID は低レベルな逃げ道です。 +エージェントターゲットは、一意なライブエージェント名、または現在そのエージェントをホストしているペイン 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`、`maki` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 -`agent start` は既存の利用可能なシェルペインを起動対象にし、トポロジーは別に作成します。kind は Herdr の標準対話型実行ファイルを選び、`--` 以降の引数はその実行ファイルへ渡されます。`agent prompt` は一意なアクティブエージェント名を要求し、現在の bracketed paste モードを尊重してテキストと Enter をアトミックに送信します。`--wait` を付けると、その送信より後の idle、done、または blocked 状態まで待ちます。単独の `agent wait` は idle、done、blocked なら即座に返り、それ以外では working の間だけ待ちます。 +start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 -`agent read` は解決されたターミナルストリームを読みます。`agent send` はターンを送信せずにリテラルテキストを書き込みます。テキストを入力モードのキーバインドから保護して Enter と一緒に送る場合は `agent prompt` を使ってください。`agent get`、`agent focus`、`agent attach` はより広いターゲット形式を受け付けます。`agent rename` は自動検出されたエージェントに名前を割り当てられます。 +`agent prompt` は現在の bracketed paste モードを尊重し、working 中でもテキストとエンコード済み Enter をアトミックに送信します。`--wait` は送信後に最初に観測された一致ライフサイクル状態を待ち、個々のターンは追跡しません。すでに working の場合、進行中ターンの完了が待機を満たすことがあります。別々のターンが必要なら、前の待機が返ってから次を送信します。進行中ターンへの追加メッセージは `--wait` なしで送り、次の安定状態を待つときに `agent wait` を使います。`--until` は一致状態を絞り込み、`--wait` なしでは拒否されます。単独の `agent wait` は現在の状態が一致すれば即座に返ります。どちらもデフォルトは `idle`、`done`、`blocked` です。`unknown` が必要な場合は `--until unknown` を明示してください。複数の正確な状態を許可するには `--until STATUS` を繰り返します。 + +`idle` は入力待ちで、そのタブがフォーカス中の Herdr UI ですでに表示済みの状態です。`done` は同じ基礎的な idle 状態ですが、表示していないバックグラウンド作業が完了したときに使われます。そのタブをフォーカスするか `pane focus` / `agent focus` で対象にすると表示済みになりますが、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` を付けてください。 @@ -273,21 +310,19 @@ herdr terminal title clear ダイレクトアタッチからは `ctrl+b q` でデタッチします。リテラルの `ctrl+b` は `ctrl+b ctrl+b` で送ります。 `terminal title clear` は Herdr のデフォルトの外側ターミナルウィンドウタイトルを復元します。 -## Wait +## 出力待機 ペインの出力を待つ: ```bash -herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw] +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` を使います。 -```bash -herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS] -``` +`pane wait-output` は選択したスナップショットを最初にすぐ検索するため、すでに存在する出力も一致します。デフォルトのソース名は `recent` で、マッチング時は行数制限なしの recent 折り返し前出力として扱われます。`--match` は 1 行内のリテラル部分文字列を検索し、`--regex` は Rust の正規表現構文で 1 行ずつ一致します。`--raw` は ANSI エスケープシーケンスを除去しません。 -通常のコマンドやサーバーには `wait output` を使います。コーディングエージェントには `wait agent-status` を使います。 +`pane wait-output`、`agent wait`、`agent prompt --wait` は、`--timeout` を省略すると無期限に待ちます。タイムアウトまたはサーバーエラーは JSON として stderr に出力され、終了コード 1 になります。CLI 使用法のエラーは終了コード 2 です。 ## インテグレーション @@ -392,6 +427,8 @@ herdr plugin pane close <pane_id> | `recent-unwrapped` | ソフト折り返しなしの直近のスクロールバック。ログに最適。 | | `detection` | エージェントのスクリーン検出が使う下部バッファのスナップショット。 | +この表は読み取り時の意味です。`pane wait-output` に限り、`recent` と `recent-unwrapped` はどちらも recent の折り返し前スナップショットを検索し、デフォルト表記は `recent` です。 + ## 環境変数 | 変数 | 目的 | 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 be2ad285..b1a2f201 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -77,7 +77,7 @@ herdr pane split w1:p1 --direction right --ratio 0.333 エージェントを待つ: ```bash -herdr wait agent-status w1:p1 --status done +herdr agent wait w1:p1 --until done ``` ペインの出力を読む: @@ -102,12 +102,12 @@ herdr pane read w1:p2 --source recent --lines 50 | ペイン | `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`、`agent.prompt`、`agent.rename`、`agent.focus`、`agent.start` | +| エージェント | `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` | -一部の CLI コマンドは、これらのメソッドの便利ラッパーです。たとえば `herdr agent wait` は、エージェントターゲットを解決してからペインのエージェント状態イベントを購読します。 +`agent.wait` はサーバー所有でイベント駆動です。解決したペイン占有者に固定されるため、置換されたエージェントが待機を満たすことはありません。`agent.prompt` は `until` と `timeout_ms` を持つ省略可能な `wait` オブジェクトを受け付けます。これにより、プロンプト送信と待機開始を 1 つのリクエストで行い、別々の呼び出し間の競合を避けられます。 `session.snapshot` は、独自のローカルランタイムキャッシュを持つクライアント向けに、一度限りのブートストラップスナップショットを返します。レスポンスには、バージョン/プロトコルメタデータ、フォーカス中のワークスペース/タブ/ペイン id、ワークスペース、タブ、ペイン、タブレイアウト、エージェントの各レコードが含まれます。これは購読ではありません。取得後はリソースイベントを購読し、そのイベントでローカルキャッシュを更新してください。再接続後やキャッシュが古い可能性がある場合は、`session.snapshot` を再度呼び出します。ワークスペースレコードには、関連付けられた worktree の出自情報も含まれます。リポジトリ全体の worktree 検出には引き続き `worktree.list` を使います。 @@ -589,8 +589,8 @@ herdr pane read w1:p1 --source detection エージェントとスクリプトの協調には wait を使います。 ```bash -herdr wait agent-status w1:p1 --status done -herdr wait agent-status w1:p1 --status blocked +herdr agent wait w1:p1 --until done +herdr agent wait w1:p1 --until blocked ``` エージェントの wait は、任意のコマンドの完了ではなく意味的な状態を観測します。 diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 82778d5b..66072d3c 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -81,7 +81,7 @@ herdr pane split w1:p1 --direction right --ratio 0.333 Wait for an agent: ```bash -herdr wait agent-status w1:p1 --status done +herdr agent wait w1:p1 --until done ``` Read pane output: @@ -106,12 +106,12 @@ Raw socket method names use dot notation: | 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`, `agent.prompt`, `agent.rename`, `agent.focus`, `agent.start` | +| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send_keys`, `agent.prompt`, `agent.wait`, `agent.rename`, `agent.focus`, `agent.start` | | 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` | -Some CLI commands are conveniences around these methods. For example, `herdr agent wait` resolves an agent target and then subscribes to pane agent state events. +`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. `session.snapshot` returns a one-time bootstrap snapshot for clients that keep their own local runtime cache. The response includes version/protocol metadata, @@ -721,8 +721,8 @@ herdr pane read w1:p1 --source detection Use waits to coordinate agents and scripts. ```bash -herdr wait agent-status w1:p1 --status done -herdr wait agent-status w1:p1 --status blocked +herdr agent wait w1:p1 --until done +herdr agent wait w1:p1 --until blocked ``` Agent waits observe semantic state, not arbitrary command completion. 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 new file mode 100644 index 00000000..b25aadf0 --- /dev/null +++ b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx @@ -0,0 +1,122 @@ +--- +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` 和 `maki`。`--` 后的参数会原样传给该可执行文件。 + +`agent start` 只有在 Herdr 于同一终端检测到预期智能体,并确认它可接受交互输入后才返回。默认等待启动 30 秒;`--timeout` 必须大于 3000 且不超过 300000 毫秒。 + +```bash +herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 +``` + +使用固定 ID 的等价示例: + +```bash +herdr pane split --current --direction right --no-focus +herdr agent start reviewer --kind codex --pane w1:p2 -- -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 也可以提交。使用 `agent send-keys` 进行 `esc`、`up`、`enter`、`ctrl+c` 等交互;`escape` 也是 `esc` 的别名。只有在明确需要原始终端控制时才使用窗格输入命令。 + +窗格输入直接指定终端,不关心当前进程。智能体输入会解析实时智能体;如果该智能体已不再控制此窗格,操作会被拒绝。 + +`agent prompt --wait` 会立即提交提示,然后等待此后首次观察到的匹配生命周期状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。要把提示作为独立轮次运行,请等前一次等待返回后再发送下一条。要用多条消息引导当前轮次,请不带 `--wait` 发送,再在需要等待下一个稳定状态时使用 `agent wait`。独立的 `agent wait` 观察当前智能体;如果状态已经匹配,会立即返回。两者默认都匹配 `idle`、`done` 或 `blocked`。要接受多个精确状态,重复写 `--until`,例如 `--until idle --until done`;需要 `unknown` 时请明确使用 `--until unknown`。在 `agent prompt` 中,`--until` 必须与 `--wait` 一起使用。 + +`idle` 表示智能体正等待输入,且其标签页已在聚焦的 Herdr 界面中显示。`done` 是相同的底层 idle 状态,但用于后台工作完成后,直到该标签页获得焦点或 `pane focus` / `agent focus` 指向它。仅通过 CLI 读取不会把它标记为已查看。`blocked` 表示 Herdr 识别到了审批或提问界面。`unknown` 表示智能体存在,但 Herdr 无法可靠判断其生命周期;它不代表工作成功完成。区别重要时,请指定精确的 `--until` 状态。 + +`pane wait-output` 不解释智能体生命周期。它轮询选定的终端快照并立即进行第一次搜索,所以已经存在的文本也会匹配。默认来源名是 `recent`;匹配时会把它作为 recent 的未折行输出来处理。`--lines` 限制搜索范围;`--regex` 使用 Rust 正则表达式语法并逐行匹配;`--raw` 在匹配时保留 ANSI 转义序列。 + +在 CLI 中,`pane read` 和 `agent read` 都直接打印终端文本。默认输出去除 ANSI 转义的 UTF-8 文本;来源包含样式时,使用 `--format ansi` 或 `--ansi` 保留终端转义。`detection` 来源始终是纯文本。对所有 read 来源,`--lines N` 都返回末尾 N 个逻辑行。socket API 在 `.result.read.text` 返回文本。 + +成功的 `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/next/website/src/content/docs/zh-cn/agent-skill.mdx b/docs/next/website/src/content/docs/zh-cn/agent-skill.mdx index a7657053..f905f347 100644 --- a/docs/next/website/src/content/docs/zh-cn/agent-skill.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agent-skill.mdx @@ -58,7 +58,7 @@ claude ## 面向智能体的参考 -完整的命令指南就在技能文件本身。它涵盖窗格 ID、`pane split`、`pane run`、`pane read`、`wait output`、`wait agent-status`、工作区和标签页命令,以及协作配方。 +完整的命令指南就在技能文件本身。它涵盖窗格 ID、`pane split`、`pane run`、`pane read`、`pane wait-output`、`agent wait`、工作区和标签页命令,以及协作配方。 在这里阅读源文件: 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 ba6e96b7..fb478081 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -5,6 +5,8 @@ description: 了解 Herdr 能检测什么、智能体状态如何工作,以及é› Herdr 为同时运行多个编程智能体而生。每个智能体都待在一个真实的终端窗格里,shell、日志、提示符和运行中的进程都完好无损。Herdr 跟踪哪些窗格里有智能体,把它们的状态汇总到标签页和工作区,让你直接跳到需要关注的窗格,而不是手动轮询每个终端。 +要从脚本或另一个智能体协调它们,请参阅[智能体自动化](/zh-cn/docs/agent-automation/)。 + ## 受支持的智能体 常见编程智能体开箱即用地支持自动检测。重要的区别不在于 Herdr 能不能看到某个智能体,而在于允许哪个信号来决定 `idle`、`working` 和 `blocked`。 @@ -110,7 +112,7 @@ herdr agent rename w1:p1 reviewer herdr agent rename reviewer --clear ``` -目标可以是终端 ID、唯一的智能体名称、检测到或上报的智能体标签,以及旧式窗格 ID。 +目标使用唯一的实时智能体名称,或当前承载该智能体的窗格 ID。终端 ID 和单独的智能体 kind 标签不能作为目标。 ## 自定义状态标签 @@ -131,38 +133,6 @@ herdr pane report-metadata w1:p1 \ 智能体侧边栏行也可以选择使用 `terminal_title` 或 `terminal_title_stripped`;两者都不在默认行中。前者显示经过安全规范化的最新 OSC 0/2 终端标题,后者会移除开头一个已识别的活动或旋转指示符字形及其后的空白。这些值由 Herdr 服务器所有,冷重启后不会保留,并且独立于元数据标题和语义智能体状态。因此,如果移除后的文本不变,旋转动画可以改变原始标题而不触发窗格更新。 -## 从 CLI 启动智能体 - -先用工作区、标签页、窗格和布局命令创建拓扑,再把一个现有 shell 窗格启动为具有唯一名称的受支持交互式智能体: - -```bash -herdr pane split --current --direction right --no-focus -herdr agent start reviewer --kind pi --pane w1:p2 -``` - -`agent start` 不会创建或拆分拓扑。Herdr 为请求的 kind 选择标准可执行文件,用唯一名称标记目标窗格,并等待交互式智能体被检测为 idle、done 或 blocked。原生参数可以放在 `--` 之后: - -```bash -herdr agent start reviewer --kind pi --pane w1:p2 -- --name scratch --no-session -``` - -按名称提交提示。`--wait` 会把等待绑定到该提示,并在智能体完成或变为 blocked 时返回: - -```bash -herdr agent prompt reviewer "Review the current diff" --wait --timeout 120000 -``` - -`agent prompt` 会在发送 Enter 前遵循终端当前的 bracketed paste 模式,因此 `A != B` 之类的文本不会触发智能体的输入模式快捷键。提交提示时请使用它;`agent send` 仍是底层的字面文本操作,不会提交一轮对话。 - -对于已经处于 working 的智能体,`agent wait` 会在状态变为 idle、done 或 blocked 时返回;如果当前已经是这些状态之一,则立即返回。 - -```bash -herdr agent wait reviewer --timeout 120000 -herdr agent read reviewer --source recent-unwrapped --lines 120 -``` - -普通终端、服务器、测试、shell、拓扑和底层终端输入请使用 `herdr pane ...` 命令。 - ## 直接附加到智能体 把当前终端附加到某一个智能体终端,而不是完整的 Herdr UI: 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 61666ba8..a4facb32 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 @@ -126,6 +126,8 @@ herdr workspace close <workspace_id> 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 @@ -150,6 +152,10 @@ 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 @@ -173,14 +179,20 @@ 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`。 + +执行 `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] +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` 来源始终是纯文本。对所有来源,`--lines N` 都返回末尾 N 个逻辑行。`agent read` 使用相同的输出和行数行为。 + 发送输入: ```bash @@ -191,6 +203,8 @@ 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`;单独的发送操作仍是底层操作,不会提交命令。 从自定义钩子上报智能体状态: @@ -204,8 +218,23 @@ herdr pane report-agent <pane_id> \ [--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。 @@ -237,26 +266,34 @@ herdr pane report-metadata <pane_id> \ ## 智能体 +窗格与智能体的模型和完整协调示例见[智能体自动化](/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 <target> <text> -herdr agent prompt <name> <text> [--wait] [--timeout MS] +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 <name> [--timeout MS] +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。名称和标签是智能体的身份。终端 ID 和旧式窗格 ID 是底层的逃生舱。 +智能体目标只能是唯一的实时智能体名称,或当前承载该智能体的窗格 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` 和 `maki`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 -`agent start` 会在现有可用 shell 窗格中启动智能体;拓扑必须单独创建。kind 选择 Herdr 的标准交互式可执行文件,`--` 之后的参数会传给该程序。`agent prompt` 要求唯一的活动智能体名称,遵循当前的 bracketed paste 模式,并原子地提交文本和 Enter。加上 `--wait` 后,它会等待该提交之后的 idle、done 或 blocked 状态。单独的 `agent wait` 在当前为 idle、done 或 blocked 时立即返回,否则只在智能体处于 working 时等待。 +start 只有在预期智能体占用同一终端并可接受交互输入后才返回。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 -`agent read` 读取解析出的终端流。`agent send` 只写入字面文本,不会提交一轮对话;需要保护文本不触发输入模式快捷键并发送 Enter 时,请使用 `agent prompt`。`agent get`、`agent focus` 和 `agent attach` 接受更广泛的目标形式。`agent rename` 可以为自动检测到的智能体分配名称。 +`agent prompt` 遵循当前的 bracketed paste 模式,即使智能体处于 working 也会原子地提交文本和编码后的 Enter。`--wait` 会等待提交后首次观察到的匹配生命周期状态,但不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。需要独立轮次时,请等前一次等待返回后再发送下一条。向当前轮次发送附加消息时不要带 `--wait`,并在需要等待下一个稳定状态时使用 `agent wait`。`--until` 用于缩小匹配状态,不带 `--wait` 时会被拒绝。单独的 `agent wait` 在当前状态匹配时立即返回。两者默认匹配 `idle`、`done` 或 `blocked`;需要 `unknown` 时请明确使用 `--until unknown`。重复 `--until STATUS` 可接受多个精确状态。 + +`idle` 表示智能体在等待输入,且其标签页已在聚焦的 Herdr 界面中显示。`done` 是相同的底层 idle 状态,用于未查看的后台工作完成后。聚焦该标签页或用 `pane focus` / `agent focus` 指向它会标记为已查看;仅通过 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`。 @@ -273,21 +310,19 @@ herdr terminal title clear 从直接附加中用 `ctrl+b q` 分离。用 `ctrl+b ctrl+b` 发送字面的 `ctrl+b`。 `terminal title clear` 恢复 Herdr 默认的外层终端窗口标题。 -## 等待 +## 输出等待 等待窗格中的输出: ```bash -herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw] +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`。 -```bash -herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS] -``` +`pane wait-output` 会立即检查所选快照,所以已经存在的输出也能匹配。默认来源名是 `recent`;匹配时会把它作为不限制行数的 recent 未折行输出。`--match` 在单行中查找字面子串;`--regex` 使用 Rust 正则表达式语法并逐行匹配。`--raw` 保留 ANSI 转义序列。 -普通命令和服务器用 `wait output`。编程智能体用 `wait agent-status`。 +`pane wait-output`、`agent wait` 和 `agent prompt --wait` 在省略 `--timeout` 时会无限等待。超时或服务器错误以 JSON 写到 stderr,退出状态为 1。CLI 用法错误的退出状态为 2。 ## 集成 @@ -392,6 +427,8 @@ herdr plugin pane close <pane_id> | `recent-unwrapped` | 不带软折行的最近回滚内容。最适合日志。 | | `detection` | 智能体屏幕检测使用的底部缓冲区快照。 | +此表描述读取时的含义。仅对 `pane wait-output` 而言,`recent` 和 `recent-unwrapped` 都搜索 recent 的未折行快照,默认写法仍是 `recent`。 + ## 环境变量 | 变量 | 用途 | 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 89c2e771..543aeb39 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 @@ -77,7 +77,7 @@ herdr pane split w1:p1 --direction right --ratio 0.333 等待智能体: ```bash -herdr wait agent-status w1:p1 --status done +herdr agent wait w1:p1 --until done ``` 读取窗格输出: @@ -102,12 +102,12 @@ herdr pane read w1:p2 --source recent --lines 50 | 窗格 | `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`、`agent.prompt`、`agent.rename`、`agent.focus`、`agent.start` | +| 智能体 | `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` | -一些 CLI 命令是这些方法的便捷包装。比如 `herdr agent wait` 先解析智能体目标,然后订阅窗格智能体状态事件。 +`agent.wait` 由服务器拥有并由事件驱动。它会固定到已解析的窗格占用者,因此替换后的智能体不能满足该等待。`agent.prompt` 接受可选的 `wait` 对象,其中包含 `until` 和 `timeout_ms`;这样可在一个请求中提交提示并开始等待,避免两个独立调用之间的竞态。 `session.snapshot` 为维护本地运行时缓存的客户端返回一次性引导快照。响应包含版本/协议元数据、当前聚焦的工作区/标签页/窗格 id、工作区记录、标签页记录、窗格记录、标签页布局快照和智能体记录。它不是订阅;读取后应订阅资源事件,并根据事件更新本地缓存。重新连接后或本地缓存可能已过期时,再次调用 `session.snapshot`。工作区记录也包含关联 worktree 的来源信息。完整的仓库 worktree 发现仍使用 `worktree.list`。 @@ -589,8 +589,8 @@ herdr pane read w1:p1 --source detection 用等待来协调智能体和脚本。 ```bash -herdr wait agent-status w1:p1 --status done -herdr wait agent-status w1:p1 --status blocked +herdr agent wait w1:p1 --until done +herdr agent wait w1:p1 --until blocked ``` 智能体等待观察的是语义状态,不是任意命令的完成。 diff --git a/src/api/mod.rs b/src/api/mod.rs index 618fa4f9..badf1707 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -45,6 +45,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::AgentFocus(_) | Method::AgentStart(_) | Method::AgentPrompt(_) + | Method::AgentSendKeys(_) | Method::PaneSplit(_) | Method::PaneSwap(_) | Method::PaneMove(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 41f372e3..efaee54c 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -109,8 +109,8 @@ pub enum Method { AgentRead(AgentReadParams), #[serde(rename = "agent.explain")] AgentExplain(AgentTarget), - #[serde(rename = "agent.send")] - AgentSend(AgentSendParams), + #[serde(rename = "agent.send_keys")] + AgentSendKeys(AgentSendKeysParams), #[serde(rename = "agent.rename")] AgentRename(AgentRenameParams), #[serde(rename = "agent.focus")] @@ -119,6 +119,8 @@ pub enum Method { AgentStart(AgentStartParams), #[serde(rename = "agent.prompt")] AgentPrompt(AgentPromptParams), + #[serde(rename = "agent.wait")] + AgentWait(AgentWaitParams), #[serde(rename = "pane.split")] PaneSplit(PaneSplitParams), #[serde(rename = "pane.swap")] diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index 8592e56b..59949293 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -17,9 +17,26 @@ pub struct AgentReadParams { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct AgentSendParams { +pub struct AgentSendKeysParams { pub target: String, - pub text: String, + pub keys: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AgentWaitParams { + pub target: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub until: Vec<AgentStatus>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AgentPromptWaitOptions { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub until: Vec<AgentStatus>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -45,6 +62,8 @@ pub struct AgentStartParams { pub struct AgentPromptParams { pub target: String, pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wait: Option<AgentPromptWaitOptions>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 25ccc8ec..50b5d522 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -520,6 +520,10 @@ pub enum EventData { workspace_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] agent: Option<String>, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + released: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + final_status: Option<AgentStatus>, }, PaneAgentStatusChanged { pane_id: String, diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index f20b9095..5a267ff1 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -29,6 +29,25 @@ pub enum IntegrationTarget { Mastracode, } +impl IntegrationTarget { + pub(crate) const ALL: [Self; 14] = [ + Self::Pi, + Self::Omp, + Self::Claude, + Self::Codex, + Self::Copilot, + Self::Devin, + Self::Droid, + Self::Kimi, + Self::Opencode, + Self::Kilo, + Self::Hermes, + Self::Qodercli, + Self::Cursor, + Self::Mastracode, + ]; +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct IntegrationInstallResult { pub messages: Vec<String>, diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index ad058191..e1f47efd 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -103,7 +103,6 @@ pub enum ResponseResult { }, AgentPrompted { agent: AgentInfo, - baseline_state_change_seq: u64, }, AgentList { agents: Vec<AgentInfo>, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 232c2b73..236d4f12 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -86,6 +86,7 @@ fn agent_start_and_prompt_requests_round_trip() { method: Method::AgentPrompt(AgentPromptParams { target: "reviewer".into(), text: "review this".into(), + wait: None, }), }; let prompt_json = serde_json::to_value(&prompt).unwrap(); @@ -94,6 +95,31 @@ fn agent_start_and_prompt_requests_round_trip() { serde_json::from_value::<Request>(prompt_json).unwrap(), prompt ); + + let prompt_and_wait = Request { + id: "prompt-and-wait".into(), + method: Method::AgentPrompt(AgentPromptParams { + target: "reviewer".into(), + text: "review this".into(), + wait: Some(AgentPromptWaitOptions { + until: vec![AgentStatus::Idle, AgentStatus::Done], + timeout_ms: Some(120_000), + }), + }), + }; + let prompt_and_wait_json = serde_json::to_value(&prompt_and_wait).unwrap(); + assert_eq!( + prompt_and_wait_json["params"]["wait"]["until"], + serde_json::json!(["idle", "done"]) + ); + assert_eq!( + prompt_and_wait_json["params"]["wait"]["timeout_ms"], + 120_000 + ); + assert_eq!( + serde_json::from_value::<Request>(prompt_and_wait_json).unwrap(), + prompt_and_wait + ); } #[test] diff --git a/src/api/server.rs b/src/api/server.rs index ce538853..0a36e2fa 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -14,7 +14,7 @@ use crate::api::schema::{ ErrorBody, ErrorResponse, Method, Request, ResponseResult, ServerCapabilities, SuccessResponse, }; use crate::api::subscriptions::ActiveSubscription; -use crate::api::wait::{wait_for_event, wait_for_output}; +use crate::api::wait::{prompt_agent, wait_for_agent, wait_for_event, wait_for_output}; use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub}; use crate::ipc::{ bind_local_listener, is_connection_closed_error, local_stream_peer_closed, @@ -218,62 +218,42 @@ fn handle_connection( result } Method::EventsWait(params) => { - let Some(response) = wait_for_event( + let response = wait_for_event( request_id.clone(), params, &mut stream, api_tx, event_hub, running, - )? - else { - crate::logging::api_request_completed( - &request_id, - method, - "client_disconnected", - changes_ui, - ); - return Ok(()); - }; - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result + )?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) + } + Method::AgentPrompt(params) => { + let response = prompt_agent( + request_id.clone(), + params, + &mut stream, + api_tx, + event_hub, + running, + )?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) + } + Method::AgentWait(params) => { + let response = wait_for_agent( + request_id.clone(), + params, + &mut stream, + api_tx, + event_hub, + running, + )?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } Method::PaneWaitForOutput(params) => { - let Some(response) = - wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)? - else { - crate::logging::api_request_completed( - &request_id, - method, - "client_disconnected", - changes_ui, - ); - return Ok(()); - }; - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result + let response = + wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } method_body => { let (response_write_tx, response_write_rx) = std::sync::mpsc::channel(); @@ -304,6 +284,35 @@ fn handle_connection( } } +fn finish_wait_response( + stream: &mut LocalStream, + response: Option<String>, + request_id: &str, + method: &'static str, + changes_ui: bool, +) -> std::io::Result<()> { + let Some(response) = response else { + crate::logging::api_request_completed( + request_id, + method, + "client_disconnected", + changes_ui, + ); + return Ok(()); + }; + let result = write_text_line_allow_disconnect(stream, &response); + match &result { + Ok(()) => crate::logging::api_request_completed( + request_id, + method, + api_response_outcome(&response), + changes_ui, + ), + Err(err) => crate::logging::api_request_failed(request_id, method, &err.to_string()), + } + result +} + fn handle_request( request: Request, api_tx: &ApiRequestSender, @@ -367,11 +376,12 @@ fn api_method_name(method: &Method) -> &'static str { Method::AgentGet(_) => "agent.get", Method::AgentRead(_) => "agent.read", Method::AgentExplain(_) => "agent.explain", - Method::AgentSend(_) => "agent.send", + Method::AgentSendKeys(_) => "agent.send_keys", Method::AgentRename(_) => "agent.rename", Method::AgentFocus(_) => "agent.focus", Method::AgentStart(_) => "agent.start", Method::AgentPrompt(_) => "agent.prompt", + Method::AgentWait(_) => "agent.wait", Method::PaneSplit(_) => "pane.split", Method::PaneSwap(_) => "pane.swap", Method::PaneMove(_) => "pane.move", diff --git a/src/api/wait.rs b/src/api/wait.rs index d556fa96..2f8da2e3 100644 --- a/src/api/wait.rs +++ b/src/api/wait.rs @@ -126,6 +126,404 @@ pub(super) fn wait_for_output( } } +pub(super) fn wait_for_agent( + request_id: String, + params: crate::api::schema::AgentWaitParams, + stream: &mut LocalStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<Option<String>> { + let last_event_sequence = event_hub.current_sequence(); + let initial = match agent_get(&request_id, ¶ms.target, api_tx) { + Ok(agent) => agent, + Err(response) => { + return serde_json::to_string(&response) + .map(Some) + .map_err(std::io::Error::other); + } + }; + let until = agent_wait_statuses(params.until); + if agent_wait_matches(&initial, &until, None) { + return agent_wait_success(request_id, initial).map(Some); + } + + match wait_for_resolved_agent( + request_id.clone(), + ResolvedAgentWait { + target: params.target, + until, + timeout_ms: params.timeout_ms, + initial, + last_event_sequence, + after_state_change_seq: None, + accept_transient_status: true, + }, + stream, + api_tx, + event_hub, + running, + )? { + Some(AgentWaitOutcome::Matched(agent)) => agent_wait_success(request_id, *agent).map(Some), + Some(AgentWaitOutcome::Response(response)) => Ok(Some(response)), + None => Ok(None), + } +} + +pub(super) fn prompt_agent( + request_id: String, + params: crate::api::schema::AgentPromptParams, + stream: &mut LocalStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<Option<String>> { + let Some(wait) = params.wait.clone() else { + return Ok(Some(dispatch_to_app_with_timeout( + Request { + id: request_id, + method: Method::AgentPrompt(params), + }, + api_tx, + None, + ))); + }; + + let last_event_sequence = event_hub.current_sequence(); + let before_prompt = match agent_get(&request_id, ¶ms.target, api_tx) { + Ok(agent) => agent, + Err(response) => { + return serde_json::to_string(&response) + .map(Some) + .map_err(std::io::Error::other); + } + }; + let target = params.target.clone(); + let prompt_response = dispatch_to_app_with_timeout( + Request { + id: request_id.clone(), + method: Method::AgentPrompt(params), + }, + api_tx, + None, + ); + let Ok(prompted) = agent_from_response(&request_id, &prompt_response) else { + return Ok(Some(prompt_response)); + }; + if !agent_wait_identity_matches( + &prompted, + &before_prompt.terminal_id, + before_prompt.name.as_deref().filter(|name| *name == target), + before_prompt.agent.as_deref(), + ) { + return agent_wait_not_running(request_id).map(Some); + } + + // The prompt response is produced on the app thread after input is queued. + // Requiring a later sequence prevents pre-prompt completion from satisfying this wait. + let prompt_state_change_seq = prompted.state_change_seq; + let Some(outcome) = wait_for_resolved_agent( + request_id.clone(), + ResolvedAgentWait { + target, + until: agent_wait_statuses(wait.until), + timeout_ms: wait.timeout_ms, + initial: prompted, + last_event_sequence, + after_state_change_seq: Some(prompt_state_change_seq), + accept_transient_status: false, + }, + stream, + api_tx, + event_hub, + running, + )? + else { + return Ok(None); + }; + let agent = match outcome { + AgentWaitOutcome::Matched(agent) => *agent, + AgentWaitOutcome::Response(response) => return Ok(Some(response)), + }; + serde_json::to_string(&SuccessResponse { + id: request_id, + result: ResponseResult::AgentPrompted { agent }, + }) + .map(Some) + .map_err(std::io::Error::other) +} + +struct ResolvedAgentWait { + target: String, + until: Vec<crate::api::schema::AgentStatus>, + timeout_ms: Option<u64>, + initial: crate::api::schema::AgentInfo, + last_event_sequence: u64, + after_state_change_seq: Option<u64>, + accept_transient_status: bool, +} + +enum AgentWaitOutcome { + Matched(Box<crate::api::schema::AgentInfo>), + Response(String), +} + +fn wait_for_resolved_agent( + request_id: String, + wait: ResolvedAgentWait, + stream: &mut LocalStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<Option<AgentWaitOutcome>> { + let deadline = wait + .timeout_ms + .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms)); + let expected_terminal_id = wait.initial.terminal_id.clone(); + let expected_name = wait + .initial + .name + .as_ref() + .filter(|name| name.as_str() == wait.target) + .cloned(); + let expected_agent = wait.initial.agent.clone(); + let pane_id = wait.initial.pane_id.clone(); + let mut last_event_sequence = wait.last_event_sequence; + + loop { + if should_stop_connection(stream, running)? { + return Ok(None); + } + + let mut should_probe = false; + let mut matched_event_status = None; + for (sequence, event) in event_hub.events_after(last_event_sequence) { + last_event_sequence = sequence; + match event.data { + EventData::PaneAgentDetected { + pane_id: event_pane, + agent, + released, + final_status, + .. + } if event_pane == pane_id => { + if released { + if let Some(status) = final_status + .filter(|status| wait.until.contains(status)) + .or(matched_event_status) + { + let mut matched = wait.initial.clone(); + matched.agent_status = status; + return Ok(Some(AgentWaitOutcome::Matched(Box::new(matched)))); + } + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + if agent.is_some() && expected_agent.is_some() && agent != expected_agent { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + should_probe = true; + } + EventData::PaneAgentStatusChanged { + pane_id: event_pane, + agent_status, + .. + } if event_pane == pane_id => { + if wait.accept_transient_status && wait.until.contains(&agent_status) { + matched_event_status = Some(agent_status); + } + should_probe = true; + } + EventData::PaneUpdated { pane } if pane.pane_id == pane_id => should_probe = true, + EventData::PaneMoved { + previous_pane_id, .. + } if previous_pane_id == pane_id => { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + EventData::PaneClosed { + pane_id: event_pane, + .. + } + | EventData::PaneExited { + pane_id: event_pane, + .. + } if event_pane == pane_id => { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + _ => {} + } + } + + if should_probe { + let current = match agent_get(&request_id, &wait.target, api_tx) { + Ok(agent) => agent, + Err(response) => { + return agent_wait_probe_error(response) + .map(AgentWaitOutcome::Response) + .map(Some); + } + }; + if !agent_wait_identity_matches( + ¤t, + &expected_terminal_id, + expected_name.as_deref(), + expected_agent.as_deref(), + ) { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + if let Some(status) = matched_event_status { + let mut matched = current; + matched.agent_status = status; + return Ok(Some(AgentWaitOutcome::Matched(Box::new(matched)))); + } + if agent_wait_matches(¤t, &wait.until, wait.after_state_change_seq) { + return Ok(Some(AgentWaitOutcome::Matched(Box::new(current)))); + } + } + + if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { + return serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "timeout".into(), + message: "timed out waiting for agent status".into(), + }, + }) + .map(AgentWaitOutcome::Response) + .map(Some) + .map_err(std::io::Error::other); + } + std::thread::sleep(CONNECTION_POLL_INTERVAL); + } +} + +fn agent_wait_statuses( + until: Vec<crate::api::schema::AgentStatus>, +) -> Vec<crate::api::schema::AgentStatus> { + if until.is_empty() { + vec![ + crate::api::schema::AgentStatus::Idle, + crate::api::schema::AgentStatus::Done, + crate::api::schema::AgentStatus::Blocked, + ] + } else { + until + } +} + +fn agent_wait_identity_matches( + agent: &crate::api::schema::AgentInfo, + expected_terminal_id: &str, + expected_name: Option<&str>, + expected_agent: Option<&str>, +) -> bool { + agent.terminal_id == expected_terminal_id + && expected_name.is_none_or(|name| agent.name.as_deref() == Some(name)) + && match (expected_agent, agent.agent.as_deref()) { + (Some(expected), Some(current)) => expected == current, + (Some(_), None) => agent.name.is_some(), + (None, _) => true, + } +} + +fn agent_wait_matches( + agent: &crate::api::schema::AgentInfo, + until: &[crate::api::schema::AgentStatus], + after_state_change_seq: Option<u64>, +) -> bool { + until.contains(&agent.agent_status) + && after_state_change_seq.is_none_or(|baseline| agent.state_change_seq > baseline) +} + +fn agent_get( + request_id: &str, + target: &str, + api_tx: &ApiRequestSender, +) -> Result<crate::api::schema::AgentInfo, ErrorResponse> { + let response = dispatch_to_app_with_timeout( + Request { + id: format!("{request_id}:agent"), + method: Method::AgentGet(crate::api::schema::AgentTarget { + target: target.to_string(), + }), + }, + api_tx, + Some(APP_RESPONSE_TIMEOUT), + ); + agent_from_response(request_id, &response) +} + +fn agent_from_response( + request_id: &str, + response: &str, +) -> Result<crate::api::schema::AgentInfo, ErrorResponse> { + let value: serde_json::Value = serde_json::from_str(response).map_err(|_| ErrorResponse { + id: request_id.into(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode agent response".into(), + }, + })?; + if value.get("error").is_some() { + let error = serde_json::from_value(value["error"].clone()).map_err(|_| ErrorResponse { + id: request_id.into(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode agent error".into(), + }, + })?; + return Err(ErrorResponse { + id: request_id.into(), + error, + }); + } + serde_json::from_value(value["result"]["agent"].clone()).map_err(|_| ErrorResponse { + id: request_id.into(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode agent result".into(), + }, + }) +} + +fn agent_wait_success( + request_id: String, + agent: crate::api::schema::AgentInfo, +) -> std::io::Result<String> { + serde_json::to_string(&SuccessResponse { + id: request_id, + result: ResponseResult::AgentInfo { agent }, + }) + .map_err(std::io::Error::other) +} + +fn agent_wait_not_running(request_id: String) -> std::io::Result<String> { + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "agent_not_running".into(), + message: "agent is no longer running in the target pane".into(), + }, + }) + .map_err(std::io::Error::other) +} + +fn agent_wait_probe_error(response: ErrorResponse) -> std::io::Result<String> { + if response.error.code == "agent_not_found" { + return agent_wait_not_running(response.id); + } + serde_json::to_string(&response).map_err(std::io::Error::other) +} + pub(super) fn wait_for_event( request_id: String, params: EventsWaitParams, @@ -246,3 +644,35 @@ fn wait_matched_response(request_id: &str, event: serde_json::Value) -> String { }) .unwrap() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_wait_probe_only_translates_agent_disappearance() { + let disappeared = agent_wait_probe_error(ErrorResponse { + id: "wait".into(), + error: ErrorBody { + code: "agent_not_found".into(), + message: "missing".into(), + }, + }) + .unwrap(); + let disappeared: ErrorResponse = serde_json::from_str(&disappeared).unwrap(); + assert_eq!(disappeared.id, "wait"); + assert_eq!(disappeared.error.code, "agent_not_running"); + + let unavailable = agent_wait_probe_error(ErrorResponse { + id: "wait".into(), + error: ErrorBody { + code: "server_unavailable".into(), + message: "timed out waiting for app response".into(), + }, + }) + .unwrap(); + let unavailable: ErrorResponse = serde_json::from_str(&unavailable).unwrap(); + assert_eq!(unavailable.id, "wait"); + assert_eq!(unavailable.error.code, "server_unavailable"); + } +} diff --git a/src/app/actions.rs b/src/app/actions.rs index b21de729..b7d335b1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -14,6 +14,7 @@ use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; +use super::api_helpers::pane_agent_status; use super::state::{ navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, @@ -245,6 +246,9 @@ pub struct PaneStateUpdate { pub state: AgentState, pub seen: bool, pub presentation: crate::terminal::EffectivePresentation, + pub agent_name_changed: bool, + pub agent_released: bool, + pub agent_release_status: Option<crate::api::schema::AgentStatus>, } // --------------------------------------------------------------------------- @@ -1041,6 +1045,9 @@ impl AppState { state: change.state, seen, presentation: change.presentation.clone(), + agent_name_changed: false, + agent_released: false, + agent_release_status: None, }; Some(update) }) @@ -2677,7 +2684,7 @@ impl AppState { observed_at, } => self .update_terminal_state(pane_id, |terminal| { - let mutation = terminal.set_detected_state_with_screen_signals_at( + Some(terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, @@ -2685,11 +2692,7 @@ impl AppState { visible_working, process_exited, observed_at, - ); - if process_exited { - terminal.reconcile_managed_agent_at(observed_at, true); - } - Some(mutation) + )) }) .into_iter() .collect(), @@ -2851,16 +2854,27 @@ impl AppState { .attached_terminal_id .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; - let (mutation, managed_changed) = { + let now = Instant::now(); + let (mutation, managed_changed, agent_name_changed, unchanged_change) = { let terminal = self.terminals.get_mut(&terminal_id)?; + let previous_agent_name = terminal.agent_name.clone(); let mutation = update(terminal)?; - let managed_changed = terminal.reconcile_managed_agent_at(Instant::now(), false); - (mutation, managed_changed) + let managed_changed = terminal.reconcile_managed_agent_at(now, false); + let agent_name_changed = terminal.agent_name != previous_agent_name; + let unchanged_change = (mutation.agent_released || agent_name_changed) + .then(|| terminal.unchanged_effective_state_change_at(now)); + ( + mutation, + managed_changed, + agent_name_changed, + unchanged_change, + ) }; - if mutation.session_ref_changed || managed_changed { + if mutation.session_ref_changed || managed_changed || agent_name_changed { self.mark_session_dirty(); } - let change = mutation.effective_state_change?; + let agent_released = mutation.agent_released; + let change = mutation.effective_state_change.or(unchanged_change)?; if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { @@ -2881,6 +2895,9 @@ impl AppState { state: change.state, seen, presentation: change.presentation.clone(), + agent_name_changed, + agent_released, + agent_release_status: agent_released.then(|| pane_agent_status(change.state, seen)), }; Some(update) } @@ -2924,7 +2941,7 @@ impl AppState { pane_id: PaneId, ) -> Option<PaneStateUpdate> { let observed_at = std::time::Instant::now(); - self.update_terminal_state(pane_id, |terminal| { + let update = self.update_terminal_state(pane_id, |terminal| { let agent = terminal.effective_known_agent().or(terminal.detected_agent); if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() { return None; @@ -2938,7 +2955,8 @@ impl AppState { true, observed_at, )) - }) + })?; + update.agent_released.then_some(update) } fn apply_pane_state_change( @@ -5063,6 +5081,32 @@ mod tests { } #[test] + fn releasing_an_agent_alias_marks_the_session_dirty() { + let mut state = app_with_workspaces(&["active"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + state.session_dirty = false; + + state.handle_app_event(AppEvent::HookAgentReleased { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + known_agent: Some(Agent::Pi), + seq: Some(1), + }); + + assert!(state.terminals[&terminal_id].agent_name.is_none()); + assert!(state.session_dirty); + } + + #[test] fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); @@ -5470,6 +5514,11 @@ mod tests { assert_eq!(update.state, AgentState::Idle); assert_eq!(update.agent_label.as_deref(), Some("pi")); assert_eq!(update.known_agent, Some(Agent::Pi)); + assert!(update.agent_released); + assert_eq!( + update.agent_release_status, + Some(crate::api::schema::AgentStatus::Done) + ); 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 f95bf77f..ccdd4af6 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -8,6 +8,14 @@ use crate::api::schema::AgentStartParams; const DEFAULT_AGENT_START_TIMEOUT: Duration = Duration::from_secs(30); const MAX_AGENT_START_TIMEOUT: Duration = Duration::from_secs(300); const AGENT_START_SETTLE_DELAY: Duration = Duration::from_secs(3); +const INVALID_AGENT_NAME_MESSAGE: &str = "agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)"; + +fn valid_agent_name(name: &str) -> bool { + let mut chars = name.chars(); + matches!(chars.next(), Some('a'..='z')) + && name.len() <= 32 + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')) +} impl App { pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> { @@ -27,7 +35,7 @@ impl App { } pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) { - let Ok(resolved) = self.resolve_terminal_target(target) else { + let Ok(resolved) = self.resolve_agent_target(target) else { return; }; let Some(terminal_id) = self @@ -55,7 +63,7 @@ impl App { &self, target: &str, ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { - let resolved = self.resolve_terminal_target(target)?; + let resolved = self.resolve_agent_target(target)?; self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { target: target.to_string(), @@ -66,7 +74,7 @@ impl App { &mut self, target: &str, ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { - let resolved = self.resolve_terminal_target(target)?; + let resolved = self.resolve_agent_target(target)?; self.state .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); self.state.mark_active_tab_seen(); @@ -83,12 +91,13 @@ impl App { name: Option<String>, ) -> Result<crate::api::schema::AgentInfo, AgentRenameError> { let resolved = self - .resolve_terminal_target(target) + .resolve_agent_target(target) .map_err(AgentRenameError::Target)?; - let normalized_name = name.and_then(|name| { - let trimmed = name.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }); + let normalized_name = match name { + Some(name) if valid_agent_name(&name) => Some(name), + Some(_) => return Err(AgentRenameError::InvalidName), + None => None, + }; if let Some(name) = normalized_name.as_deref() { let conflicts = self.agent_name_conflicts(name, &resolved.terminal_id); @@ -113,14 +122,16 @@ impl App { if terminal.managed_agent_launch_pending() { return Err(AgentRenameError::PendingLaunch); } + if terminal.effective_agent_label().is_none() { + return Err(AgentRenameError::NotAgent); + } match normalized_name { - Some(name) => { - terminal.set_agent_name(name.clone()); - terminal.set_manual_label(name); - } + Some(name) => terminal.set_agent_name(name), None => terminal.clear_agent_name(), } self.state.mark_session_dirty(); + self.schedule_session_save(); + self.emit_pane_updated(resolved.ws_idx, resolved.pane_id); self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| { AgentRenameError::Target(TerminalTargetError::NotFound { @@ -133,8 +144,8 @@ impl App { &mut self, params: AgentStartParams, ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> { - let name = params.name.trim().to_string(); - if name.is_empty() { + let name = params.name; + if !valid_agent_name(&name) { return Err(AgentStartError::InvalidName); } let Some(kind) = crate::detect::parse_agent_label(¶ms.kind) else { @@ -154,7 +165,7 @@ impl App { candidates: conflicts, }); } - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(¶ms.pane_id) else { return Err(AgentStartError::TargetNotFound(params.pane_id)); }; let terminal_id = self @@ -193,7 +204,6 @@ impl App { return Err(AgentStartError::InvalidTimeout); } - let previous_label = terminal.manual_label.clone(); let now = Instant::now(); let terminal = self .state @@ -201,13 +211,8 @@ impl App { .get_mut(&terminal_id) .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; terminal.begin_managed_agent(name.clone(), kind, now, AGENT_START_SETTLE_DELAY, timeout); - terminal.set_manual_label(name); if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { terminal.clear_agent_name(); - match previous_label { - Some(label) => terminal.set_manual_label(label), - None => terminal.clear_manual_label(), - } return Err(AgentStartError::InputFailed(err.to_string())); } self.state.mark_session_dirty(); @@ -226,7 +231,7 @@ impl App { match err { AgentStartError::InvalidName => crate::api::schema::ErrorBody { code: "invalid_agent_name".into(), - message: "agent name must not be empty".into(), + message: INVALID_AGENT_NAME_MESSAGE.into(), }, AgentStartError::UnsupportedKind(kind) => crate::api::schema::ErrorBody { code: "unsupported_agent_kind".into(), @@ -318,6 +323,14 @@ impl App { ) -> crate::api::schema::ErrorBody { match err { AgentRenameError::Target(err) => self.agent_target_error_body(err), + AgentRenameError::InvalidName => crate::api::schema::ErrorBody { + code: "invalid_agent_name".into(), + message: INVALID_AGENT_NAME_MESSAGE.into(), + }, + AgentRenameError::NotAgent => crate::api::schema::ErrorBody { + code: "agent_not_found".into(), + message: "agent target does not currently host an agent".into(), + }, AgentRenameError::PendingLaunch => crate::api::schema::ErrorBody { code: "agent_launch_pending".into(), message: "agent name cannot change while startup is pending".into(), @@ -443,9 +456,35 @@ pub(super) enum AgentStartError { pub(super) enum AgentRenameError { Target(TerminalTargetError), + InvalidName, + NotAgent, PendingLaunch, DuplicateName { name: String, candidates: Vec<crate::api::schema::AgentInfo>, }, } + +#[cfg(test)] +mod tests { + use super::valid_agent_name; + + #[test] + fn agent_names_use_a_small_cli_safe_grammar() { + for name in ["a", "reviewer-one", "reviewer_2", &"a".repeat(32)] { + assert!(valid_agent_name(name), "expected {name:?} to be valid"); + } + for name in [ + "", + " reviewer", + "reviewer ", + "reviewer one", + "Reviewer", + "1reviewer", + "reviewer.one", + &"a".repeat(33), + ] { + assert!(!valid_agent_name(name), "expected {name:?} to be invalid"); + } + } +} diff --git a/src/app/api.rs b/src/app/api.rs index 79402e47..7c963ef4 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -556,13 +556,19 @@ impl App { }; let workspace_id = self.public_workspace_id(update.ws_idx); - if update.previous_agent_label != update.agent_label { + if update.agent_name_changed { + self.emit_pane_updated(update.ws_idx, update.pane_id); + } + + if update.previous_agent_label != update.agent_label || update.agent_released { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentDetected, data: crate::api::schema::EventData::PaneAgentDetected { pane_id: pane_id.clone(), workspace_id: workspace_id.clone(), agent: update.agent_label.clone(), + released: update.agent_released, + final_status: update.agent_release_status, }, }); } @@ -996,9 +1002,18 @@ impl App { Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), Method::AgentStart(params) => return self.handle_agent_start(request.id, params), Method::AgentPrompt(params) => return self.handle_agent_prompt(request.id, params), + Method::AgentWait(_) => { + return responses::encode_error( + request.id, + "invalid_request", + "agent.wait is handled by the api server", + ); + } Method::AgentRead(params) => return self.handle_agent_read(request.id, params), Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target), - Method::AgentSend(params) => return self.handle_agent_send(request.id, params), + Method::AgentSendKeys(params) => { + return self.handle_agent_send_keys(request.id, params) + } Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params), Method::PaneMove(params) => return self.handle_pane_move(request.id, params), @@ -1876,6 +1891,102 @@ mod tests { } #[test] + fn idle_agent_exit_emits_release_event_without_a_state_change() { + for agent_name in [None, Some("reviewer")] { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + let workspace = crate::workspace::Workspace::test_new("idle-agent-exit"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + if let Some(agent_name) = agent_name { + terminal.set_agent_name(agent_name.into()); + } + + app.handle_internal_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!(app.state.terminals[&terminal_id].agent_name.is_none()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + crate::api::schema::EventData::PaneAgentDetected { + released: true, + final_status: Some(crate::api::schema::AgentStatus::Idle), + .. + } + ))); + } + } + + #[test] + fn stale_detector_exit_does_not_release_a_newer_hook_owned_agent() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + let workspace = crate::workspace::Workspace::test_new("stale-agent-exit"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let observed_at = std::time::Instant::now(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); + terminal + .set_hook_authority_at( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + Some(1), + observed_at + std::time::Duration::from_secs(1), + ) + .unwrap(); + terminal.set_agent_name("reviewer".into()); + + app.handle_internal_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at, + }); + + let terminal = &app.state.terminals[&terminal_id]; + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(!event_hub.events_after(0).iter().any(|(_, event)| matches!( + event.data, + crate::api::schema::EventData::PaneAgentDetected { released: true, .. } + ))); + } + + #[test] fn overlay_exit_layout_updated_uses_restored_zoom_state() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index d1fbf14c..ffb9cb63 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, - PaneReadResult, ReadFormat, ReadSource, ResponseResult, + AgentPromptParams, AgentRenameParams, AgentSendKeysParams, AgentStartParams, AgentTarget, + PaneReadResult, ResponseResult, }; use crate::app::App; @@ -59,7 +59,7 @@ impl App { if params.text.is_empty() { return encode_error(id, "empty_agent_prompt", "agent prompt must not be empty"); } - let resolved = match self.resolve_terminal_target(¶ms.target) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -75,29 +75,12 @@ impl App { let Some(terminal) = self.state.terminals.get(&terminal_id) else { return agent_not_found(id, ¶ms.target); }; - let Some(expected_agent) = terminal.effective_known_agent().filter(|_| { - terminal.agent_name.as_deref() == Some(params.target.as_str()) - && terminal.state != crate::detect::AgentState::Unknown - }) else { + let Some(expected_agent) = terminal.effective_known_agent() else { return agent_not_ready(id, ¶ms.target); }; if terminal.managed_agent_launch_pending() { return agent_not_ready(id, ¶ms.target); } - if terminal.state == crate::detect::AgentState::Working { - return encode_error( - id, - "agent_working", - format!( - "agent {} is still working; wait before prompting", - params.target - ), - ); - } - if terminal.managed_agent_kind().is_some() && !terminal.managed_agent_interactive_ready() { - return agent_not_ready(id, ¶ms.target); - } - let baseline_state_change_seq = terminal.last_agent_state_change_seq.unwrap_or(0); let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; @@ -118,13 +101,7 @@ impl App { let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; - encode_success( - id, - ResponseResult::AgentPrompted { - agent, - baseline_state_change_seq, - }, - ) + encode_success(id, ResponseResult::AgentPrompted { agent }) } pub(super) fn handle_agent_read( @@ -132,7 +109,7 @@ impl App { id: String, params: crate::api::schema::AgentReadParams, ) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -140,21 +117,12 @@ impl App { else { return agent_not_found(id, ¶ms.target); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -178,7 +146,7 @@ impl App { } pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String { - let resolved = match self.resolve_terminal_target(&target.target) { + let resolved = match self.resolve_agent_target(&target.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -248,16 +216,46 @@ impl App { encode_success(id, ResponseResult::AgentExplain { explain: value }) } - pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + pub(super) fn handle_agent_send_keys( + &mut self, + id: String, + params: AgentSendKeysParams, + ) -> String { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; + let Some(terminal_id) = self + .state + .workspaces + .get(resolved.ws_idx) + .and_then(|workspace| workspace.terminal_id(resolved.pane_id)) + else { + return agent_not_found(id, ¶ms.target); + }; + let Some(expected_agent) = self + .state + .terminals + .get(terminal_id) + .and_then(|terminal| terminal.effective_known_agent()) + else { + return agent_not_ready(id, ¶ms.target); + }; let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; - if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { - return encode_error(id, "agent_send_failed", err.to_string()); + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + return agent_not_ready(id, ¶ms.target); + } + let encoded = match super::super::api_helpers::encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded) => encoded, + Err(key) => { + return encode_error(id, "invalid_key", format!("unsupported key {key}")); + } + }; + let bytes: Vec<u8> = encoded.into_iter().flatten().collect(); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "agent_send_keys_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) @@ -309,7 +307,7 @@ mod tests { } #[tokio::test] - async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() { + async fn agent_prompt_accepts_pane_ids_and_working_agents_atomically() { let mut app = app_with_agent(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] @@ -317,7 +315,7 @@ mod tests { .clone(); let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); terminal.set_agent_name("reviewer".into()); - terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 24, 0, b"", 1, @@ -325,11 +323,13 @@ mod tests { runtime.test_process_pty_bytes(b"\x1b[?2004h"); app.state.insert_test_runtime(pane_id, runtime); + let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_agent_prompt( "req".into(), AgentPromptParams { - target: "reviewer".into(), + target: public_pane_id, text: "A != B".into(), + wait: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); @@ -351,6 +351,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let raw: SuccessResponse = serde_json::from_str(&raw).unwrap(); @@ -363,10 +364,48 @@ mod tests { AgentPromptParams { target: "opencode".into(), text: "wrong target".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); - assert_eq!(error.error.code, "agent_not_ready"); + assert_eq!(error.error.code, "agent_not_found"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn agent_send_keys_validates_every_key_before_writing() { + let mut app = app_with_agent(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_name("reviewer".into()); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + let rejected = app.handle_agent_send_keys( + "req-invalid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["enter".into(), "not-a-key".into()], + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); + assert_eq!(error.error.code, "invalid_key"); + assert!(rx.try_recv().is_err()); + + let sent = app.handle_agent_send_keys( + "req-valid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["up".into(), "enter".into()], + }, + ); + let success: SuccessResponse = serde_json::from_str(&sent).unwrap(); + assert!(matches!(success.result, ResponseResult::Ok {})); + assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"\x1b[A\r")); assert!(rx.try_recv().is_err()); } @@ -395,6 +434,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); @@ -426,7 +466,7 @@ mod tests { let response = app.handle_agent_focus( "req".into(), AgentTarget { - target: "pi".into(), + target: app.public_pane_id(0, pane_id).unwrap(), }, ); @@ -436,4 +476,33 @@ mod tests { }; assert_eq!(agent.agent_status, AgentStatus::Idle); } + + #[test] + fn agent_rename_does_not_replace_the_pane_label() { + let mut app = app_with_agent(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_manual_label("shell-pane".into()); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let target = app.public_pane_id(0, pane_id).unwrap(); + + for name in [Some("reviewer".to_string()), None] { + let response = app.handle_agent_rename( + "req".into(), + AgentRenameParams { + target: target.clone(), + name, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!(success.result, ResponseResult::AgentInfo { .. })); + assert_eq!( + app.state.terminals[&terminal_id].manual_label.as_deref(), + Some("shell-pane") + ); + } + } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6a5b41fe..483d73a5 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -11,7 +11,7 @@ use crate::api::schema::{ PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, - PaneZoomResult, ReadFormat, ReadSource, ResponseResult, + PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; use crate::app::App; @@ -1179,21 +1179,12 @@ impl App { else { return pane_not_found(id, ¶ms.pane_id); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -2591,6 +2582,11 @@ mod tests { .clone(); let target = app.state.workspaces[1].tabs[0].root_pane; seed_terminal_states(&mut app); + app.state + .terminals + .get_mut(&source_terminal) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Idle); let previous_pane_id = app.public_pane_id(0, source).unwrap(); let previous_workspace_id = app.public_workspace_id(0); let target_workspace_id = app.public_workspace_id(1); @@ -2633,6 +2629,11 @@ mod tests { Some(&source_terminal) ); assert_eq!(app.parse_pane_id(&previous_pane_id), Some((0, source))); + assert!(matches!( + app.resolve_agent_target(&previous_pane_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); + assert!(app.resolve_agent_target(&move_result.pane.pane_id).is_ok()); } #[test] diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c7dc39d2..6c393dac 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -101,6 +101,58 @@ pub(super) fn pane_agent_status( } } +pub(super) fn read_terminal_snapshot( + terminal: &crate::terminal::TerminalRuntime, + source: crate::api::schema::ReadSource, + format: crate::api::schema::ReadFormat, + lines: Option<u32>, +) -> String { + use crate::api::schema::{ReadFormat, ReadSource}; + + let requested_lines = lines.unwrap_or(80).min(1000) as usize; + let text = match (format, source) { + (ReadFormat::Text, ReadSource::Visible) => terminal.visible_text(), + (ReadFormat::Text, ReadSource::Recent) => terminal.recent_text(requested_lines), + (ReadFormat::Text, ReadSource::RecentUnwrapped) => { + terminal.recent_unwrapped_text(requested_lines) + } + (ReadFormat::Text, ReadSource::Detection) => terminal.detection_text(), + (ReadFormat::Ansi, ReadSource::Visible) => terminal.visible_ansi(), + (ReadFormat::Ansi, ReadSource::Recent) => terminal.recent_ansi(requested_lines), + (ReadFormat::Ansi, ReadSource::RecentUnwrapped) => { + terminal.recent_unwrapped_ansi(requested_lines) + } + (ReadFormat::Ansi, ReadSource::Detection) => terminal.detection_text(), + }; + + if lines.is_some() && matches!(source, ReadSource::Visible | ReadSource::Detection) { + last_lines(&text, requested_lines) + } else { + text + } +} + +fn last_lines(text: &str, count: usize) -> String { + if text.is_empty() || count == 0 { + return String::new(); + } + let lines: Vec<_> = text.split_inclusive('\n').collect(); + lines[lines.len().saturating_sub(count)..].concat() +} + +#[cfg(test)] +mod read_snapshot_tests { + use super::last_lines; + + #[test] + fn last_lines_preserves_real_line_endings_and_unicode() { + assert_eq!(last_lines("one\ntwø\n三\n", 2), "twø\n三\n"); + assert_eq!(last_lines("one\ntwo\nthree", 1), "three"); + assert_eq!(last_lines("one\ntwo", 0), ""); + assert_eq!(last_lines("", 2), ""); + } +} + pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> { let trimmed = agent.trim(); if trimmed.is_empty() { diff --git a/src/app/ids.rs b/src/app/ids.rs index 9dc4371e..4bebedc6 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -141,4 +141,12 @@ impl App { .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?; Some((ws_idx, pane_id)) } + + pub(crate) fn parse_current_public_pane_id( + &self, + id: &str, + ) -> Option<(usize, crate::layout::PaneId)> { + let (ws_idx, pane_id) = self.parse_pane_id(id)?; + (self.public_pane_id(ws_idx, pane_id).as_deref() == Some(id)).then_some((ws_idx, pane_id)) + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index d556b960..bff7c865 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3847,7 +3847,7 @@ mod tests { } #[test] - fn terminal_target_resolves_terminal_id() { + fn terminal_and_agent_targets_treat_terminal_ids_differently() { let mut app = test_app(); let workspace = Workspace::test_new("terminal-target-id"); let pane = workspace.tabs[0].root_pane; @@ -3857,19 +3857,54 @@ mod tests { app.state.selected = 0; let resolved = app.resolve_terminal_target(&terminal_id).unwrap(); - - assert_eq!(resolved.ws_idx, 0); assert_eq!(resolved.pane_id, pane); assert_eq!(resolved.terminal_id, terminal_id); + + assert!(matches!( + app.resolve_agent_target(&resolved.terminal_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); + } + + #[test] + fn agent_target_rejects_a_pane_that_only_has_a_launch_command() { + let mut app = test_app(); + let workspace = Workspace::test_new("terminal-target-command"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .launch_argv = Some(vec!["just".into(), "dev".into()]); + let pane_id = app.public_pane_id(0, pane).unwrap(); + + assert!(app.resolve_terminal_target(&pane_id).is_ok()); + assert!(matches!( + app.resolve_agent_target(&pane_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); } #[test] - fn terminal_target_resolves_legacy_pane_id() { + fn terminal_target_resolves_pane_id_for_an_agent() { let mut app = test_app(); let workspace = Workspace::test_new("terminal-target-pane"); let pane = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let attached_terminal_id = app.state.workspaces[0].terminal_id(pane).cloned().unwrap(); + app.state + .terminals + .get_mut(&attached_terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); app.state.active = Some(0); app.state.selected = 0; let pane_id = app.public_pane_id(0, pane).unwrap(); @@ -3908,6 +3943,27 @@ mod tests { } #[test] + fn agent_target_treats_legacy_pane_syntax_as_a_name() { + let mut app = test_app(); + let workspace = Workspace::test_new("agent-target-name"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + terminal.set_agent_name("p_1".into()); + + let resolved = app.resolve_agent_target("p_1").unwrap(); + + assert_eq!(resolved.pane_id, pane); + assert_eq!(resolved.terminal_id, terminal_id.to_string()); + } + + #[test] fn terminal_target_reports_missing_target() { let mut app = test_app(); app.state.workspaces = vec![Workspace::test_new("terminal-target-missing")]; diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index d38d115c..a8fe394a 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -1,7 +1,5 @@ use super::{api_helpers::pane_agent_status, App}; -// Staged for #00f: the agent CLI will use this resolver once #00e provides terminal ids. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalTarget { pub ws_idx: usize, @@ -10,8 +8,6 @@ pub(crate) struct TerminalTarget { pub terminal_id: String, } -// Staged for #00f: ambiguity details are produced before the agent facade consumes them. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalTargetCandidate { pub terminal_id: String, @@ -22,8 +18,6 @@ pub(crate) struct TerminalTargetCandidate { pub agent_status: crate::api::schema::AgentStatus, } -// Staged for #00f: target errors are specified now and surfaced by agent commands next. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum TerminalTargetError { NotFound { @@ -36,8 +30,6 @@ pub(crate) enum TerminalTargetError { } impl App { - // Staged for #00f: current pane APIs stay pane-targeted while agent APIs will call this. - #[allow(dead_code)] pub(crate) fn resolve_terminal_target( &self, target: &str, @@ -51,13 +43,13 @@ impl App { return Ok(resolved); } - if let Some((ws_idx, pane_id)) = self.parse_pane_id(target) { + if let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(target) { if let Some(resolved) = self.terminal_target_for_pane(ws_idx, pane_id) { return Ok(resolved); } } - let name_matches: Vec<_> = self + let agent_matches: Vec<_> = self .terminal_targets() .into_iter() .filter(|candidate| { @@ -71,6 +63,39 @@ impl App { }) }) .collect(); + if let Some(resolved) = self.single_terminal_match(target, agent_matches)? { + return Ok(resolved); + } + + Err(TerminalTargetError::NotFound { + target: target.to_string(), + }) + } + + pub(crate) fn resolve_agent_target( + &self, + target: &str, + ) -> Result<TerminalTarget, TerminalTargetError> { + if let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(target) { + if let Some(resolved) = self + .terminal_target_for_pane(ws_idx, pane_id) + .filter(|resolved| self.target_is_agent(resolved)) + { + return Ok(resolved); + } + } + + let name_matches: Vec<_> = self + .terminal_targets() + .into_iter() + .filter(|candidate| { + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == candidate.terminal_id) + .is_some_and(|terminal| terminal.agent_name.as_deref() == Some(target)) + }) + .collect(); if let Some(resolved) = self.single_terminal_match(target, name_matches)? { return Ok(resolved); } @@ -80,8 +105,14 @@ impl App { }) } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] + fn target_is_agent(&self, target: &TerminalTarget) -> bool { + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == target.terminal_id) + .is_some_and(|terminal| terminal.is_agent_terminal()) + } + fn single_terminal_match( &self, target: &str, @@ -102,8 +133,6 @@ impl App { } } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_targets(&self) -> Vec<TerminalTarget> { self.state .workspaces @@ -127,8 +156,6 @@ impl App { .collect() } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_target_for_pane( &self, ws_idx: usize, @@ -145,8 +172,6 @@ impl App { }) } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_target_candidate( &self, ws_idx: usize, @@ -4,10 +4,8 @@ use serde::Serialize; use crate::api::client::{ApiClient, ApiClientError}; use crate::api::schema::{ - AgentStatus, ClientWindowTitleSetParams, EmptyParams, EventData, EventMatch, EventsWaitParams, - Method, OutputMatch, PaneAgentState, PaneWaitForOutputParams, ReadFormat, ReadSource, Request, - ResponseResult, SplitDirection, SubscriptionEventData, SubscriptionEventEnvelope, - SubscriptionEventKind, + AgentStatus, ClientWindowTitleSetParams, EmptyParams, Method, PaneAgentState, ReadFormat, + ReadSource, Request, SplitDirection, }; mod agent; @@ -59,11 +57,26 @@ pub enum CommandOutcome { NotCli, } +pub(super) fn print_read_response(response: &serde_json::Value) -> std::io::Result<i32> { + if response.get("error").is_some() { + eprintln!("{response}"); + return Ok(1); + } + if let Some(text) = response["result"]["read"]["text"].as_str() { + print!("{text}"); + } + Ok(0) +} + pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { let Some(command) = args.get(1).map(|arg| arg.as_str()) else { return Ok(CommandOutcome::NotCli); }; + if spec::print_requested_help(args)? { + return Ok(CommandOutcome::Handled(0)); + } + let exit_code = match command { "server" => { let Some(exit_code) = server::run_server_command(&args[2..])? else { @@ -84,7 +97,6 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { "terminal" => run_terminal_command(&args[2..])?, "pane" => pane::run_pane_command(&args[2..])?, "plugin" => plugin::run_plugin_command(&args[2..])?, - "wait" => run_wait_command(&args[2..])?, "integration" => integration::run_integration_command(&args[2..])?, "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), @@ -384,26 +396,6 @@ fn run_terminal_command(args: &[String]) -> std::io::Result<i32> { } } -fn run_wait_command(args: &[String]) -> std::io::Result<i32> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_wait_help(); - return Ok(2); - }; - - match subcommand { - "output" => wait_output(&args[1..]), - "agent-status" => wait_agent_status(&args[1..]), - "help" | "--help" | "-h" => { - print_wait_help(); - Ok(0) - } - _ => { - print_wait_help(); - Ok(2) - } - } -} - fn run_session_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_session_help(); @@ -724,214 +716,6 @@ pub(super) fn parse_attach_target(args: &[String], usage: &str) -> Result<(Strin Ok((target.clone(), takeover)) } -fn wait_output(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]"); - return Ok(2); - }; - - let pane_id = normalize_pane_id(raw_pane_id); - let mut source = ReadSource::Recent; - let mut lines = None; - let mut timeout_ms = None; - let mut strip_ansi = true; - let mut regex = false; - let mut match_value = None; - - let mut index = 1; - while index < args.len() { - match args[index].as_str() { - "--match" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --match"); - return Ok(2); - }; - match_value = Some(value.clone()); - index += 2; - } - "--source" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --source"); - return Ok(2); - }; - source = parse_read_source(value)?; - index += 2; - } - "--lines" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --lines"); - return Ok(2); - }; - lines = Some(parse_u32_flag("--lines", value)?); - index += 2; - } - "--timeout" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --timeout"); - return Ok(2); - }; - timeout_ms = Some(parse_u64_flag("--timeout", value)?); - index += 2; - } - "--regex" => { - regex = true; - index += 1; - } - "--raw" => { - strip_ansi = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(match_value) = match_value else { - eprintln!("missing required --match"); - return Ok(2); - }; - - let matcher = if regex { - OutputMatch::Regex { value: match_value } - } else { - OutputMatch::Substring { value: match_value } - }; - - let response = send_request(&Request { - id: "cli:wait:output".into(), - method: Method::PaneWaitForOutput(PaneWaitForOutputParams { - pane_id, - source, - lines, - r#match: matcher, - timeout_ms, - strip_ansi, - }), - })?; - - if response.get("error").is_some() { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); - } - - println!("{}", serde_json::to_string(&response).unwrap()); - Ok(0) -} - -fn wait_agent_status(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]"); - return Ok(2); - }; - - let pane_id = normalize_pane_id(raw_pane_id); - let mut timeout_ms = None; - let mut desired_status = None; - - let mut index = 1; - while index < args.len() { - match args[index].as_str() { - "--status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --status"); - return Ok(2); - }; - desired_status = Some(parse_agent_status(value)?); - index += 2; - } - "--timeout" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --timeout"); - return Ok(2); - }; - timeout_ms = Some(parse_u64_flag("--timeout", value)?); - index += 2; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(agent_status) = desired_status else { - eprintln!("missing required --status"); - return Ok(2); - }; - - wait_for_agent_status_change(pane_id, agent_status, timeout_ms) -} - -fn wait_for_agent_status_change( - pane_id: String, - agent_status: AgentStatus, - timeout_ms: Option<u64>, -) -> std::io::Result<i32> { - let request = Request { - id: "cli:wait:agent-status".into(), - method: Method::EventsWait(EventsWaitParams { - match_event: EventMatch::PaneAgentStatusChanged { - pane_id, - agent_status, - }, - timeout_ms, - }), - }; - let response = send_request(&request)?; - match crate::api::client::parse_response_value(response) { - Ok(success) => { - let ResponseResult::WaitMatched { event } = success.result else { - return Err(std::io::Error::other("unexpected wait response result")); - }; - let EventData::PaneAgentStatusChanged { - pane_id, - workspace_id, - agent_status, - agent, - title, - display_agent, - state_labels, - } = event.data - else { - return Err(std::io::Error::other("unexpected wait event data")); - }; - let event = SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneAgentStatusChanged, - data: SubscriptionEventData::PaneAgentStatusChanged( - crate::api::schema::PaneAgentStatusChangedEvent { - pane_id, - workspace_id, - agent_status, - agent, - title, - display_agent, - state_labels, - }, - ), - }; - println!( - "{}", - serde_json::to_string(&event).map_err(std::io::Error::other)? - ); - Ok(0) - } - Err(ApiClientError::ErrorResponse(response)) => { - if response.error.code == "timeout" { - eprintln!("timed out waiting for agent status change"); - } else { - eprintln!( - "{}", - serde_json::to_string(&response).map_err(std::io::Error::other)? - ); - } - Ok(1) - } - Err(err) => Err(api_client_error_to_io(err)), - } -} - pub(super) fn print_response(response: &serde_json::Value) -> std::io::Result<i32> { if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(response).unwrap()); @@ -1160,14 +944,6 @@ fn print_terminal_help() { eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b"); } -fn print_wait_help() { - eprintln!("herdr wait commands:"); - eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw]"); - eprintln!( - " herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]" - ); -} - fn print_session_help() { eprintln!("herdr session commands:"); eprintln!(" herdr session list [--json]"); diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 898de33c..e48d6b3b 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -1,8 +1,9 @@ use std::time::{Duration, Instant}; use crate::api::schema::{ - AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, - AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, + AgentPromptParams, AgentPromptWaitOptions, AgentReadParams, AgentRenameParams, + AgentSendKeysParams, AgentStartParams, AgentTarget, AgentWaitParams, EmptyParams, Method, + ReadFormat, ReadSource, Request, }; pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> { @@ -15,7 +16,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> { "list" => agent_list(&args[1..]), "get" => agent_get(&args[1..]), "read" => agent_read(&args[1..]), - "send" => agent_send(&args[1..]), + "send-keys" => agent_send_keys(&args[1..]), "prompt" => agent_prompt(&args[1..]), "rename" => agent_rename(&args[1..]), "focus" => agent_focus(&args[1..]), @@ -304,7 +305,10 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> { eprintln!("missing value for --timeout"); return Ok(2); }; - timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + timeout_ms = match parse_timeout(value) { + Ok(timeout_ms) => Some(timeout_ms), + Err(exit_code) => return Ok(exit_code), + }; index += 2; } other => { @@ -343,22 +347,20 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> { if response.get("error").is_some() { return super::print_response(&response); } - let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { + let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000)); + let Some(expected_terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { return super::print_response(&cli_agent_error( "cli:agent:start", "agent_start_failed", "agent start response did not include terminal_id", )); }; - let terminal_id = terminal_id.to_string(); - let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000)); let waited = wait_for_named_agent( - &terminal_id, name, - Some(timeout), - AgentWaitMode::Start { - expected_kind: &expected_kind, - }, + &pane_id, + timeout, + &expected_kind, + expected_terminal_id, ); match waited { Ok(Ok(agent)) => { @@ -442,23 +444,42 @@ fn agent_attach(args: &[String]) -> std::io::Result<i32> { fn agent_wait(args: &[String]) -> std::io::Result<i32> { let Some(target) = args.first() else { - eprintln!("usage: herdr agent wait <name> [--timeout MS]"); + eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]"); return Ok(2); }; + let mut until = Vec::new(); let mut timeout_ms = None; let mut index = 1; while index < args.len() { match args[index].as_str() { + "--until" => { + let Some(value) = args.get(index + 1) else { + eprintln!("--until requires at least one status"); + return Ok(2); + }; + let status = match super::parse_agent_status(value) { + Ok(status) => status, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + until.push(status); + index += 2; + } "--timeout" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --timeout"); return Ok(2); }; - timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + timeout_ms = match parse_timeout(value) { + Ok(timeout_ms) => Some(timeout_ms), + Err(exit_code) => return Ok(exit_code), + }; index += 2; } "help" | "--help" | "-h" => { - eprintln!("usage: herdr agent wait <name> [--timeout MS]"); + eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]"); return Ok(0); } other => { @@ -467,171 +488,71 @@ fn agent_wait(args: &[String]) -> std::io::Result<i32> { } } } - let initial = resolve_agent_target(target, "cli:agent:wait:resolve")?; - if initial.get("error").is_some() { - return super::print_response(&initial); - } - let agent = &initial["result"]["agent"]; - if agent["name"].as_str() != Some(target) { - return super::print_response(&cli_agent_error( - "cli:agent:wait", - "agent_name_not_found", - format!("named agent {target} not found"), - )); - } - match agent["agent_status"].as_str().unwrap_or("unknown") { - "idle" | "done" | "blocked" => return super::print_response(&initial), - "unknown" => { - return super::print_response(&cli_agent_error( - "cli:agent:wait", - "agent_not_running", - "agent is no longer running", - )) - } - _ => {} - } - let Some(terminal_id) = agent["terminal_id"].as_str() else { - return super::print_response(&cli_agent_error( - "cli:agent:wait", - "agent_wait_failed", - "agent response did not include terminal_id", - )); - }; - let timeout = timeout_ms.map(Duration::from_millis); - let waited = match wait_for_named_agent(terminal_id, target, timeout, AgentWaitMode::Current) { - Ok(waited) => waited, - Err(err) => { - return print_agent_transport_error( - err, - "cli:agent:wait", - "agent_wait_transport_failed", - ) - } - }; - match waited { - Ok(agent) => super::print_response(&serde_json::json!({ - "id": "cli:agent:wait", - "result": { "type": "agent_info", "agent": agent } - })), - Err(error) => super::print_response(&error), - } -} - -#[derive(Clone, Copy)] -enum AgentWaitMode<'a> { - Start { expected_kind: &'a str }, - Current, - AfterPrompt { baseline_state_change_seq: u64 }, + super::print_response(&super::send_request(&Request { + id: "cli:agent:wait".into(), + method: Method::AgentWait(AgentWaitParams { + target: target.clone(), + until, + timeout_ms, + }), + })?) } fn wait_for_named_agent( - lookup_target: &str, - expected_name: &str, - timeout: Option<Duration>, - mode: AgentWaitMode<'_>, + name: &str, + fallback_pane_id: &str, + timeout: Duration, + expected_kind: &str, + expected_terminal_id: &str, ) -> std::io::Result<Result<serde_json::Value, serde_json::Value>> { let started_at = Instant::now(); - let deadline = timeout.and_then(|timeout| started_at.checked_add(timeout)); + let deadline = started_at.checked_add(timeout); let mut first_poll = true; loop { if deadline.is_some_and(|deadline| Instant::now() >= deadline) { - if matches!(mode, AgentWaitMode::Start { .. }) { - // Let the server reconcile its matching startup deadline before - // returning so the pending name is immediately reusable. - let _ = resolve_agent_target_unchecked(lookup_target, "cli:agent:start:timeout"); - } - return Ok(Err(agent_wait_timeout(mode))); + // Let the server reconcile its matching startup deadline before + // returning so the pending name is immediately reusable. + let _ = resolve_agent_target_unchecked(name, "cli:agent:start:timeout"); + return Ok(Err(agent_wait_timeout())); } - let poll_id = match mode { - AgentWaitMode::Start { .. } => "cli:agent:start", - AgentWaitMode::Current => "cli:agent:wait", - AgentWaitMode::AfterPrompt { .. } => "cli:agent:prompt", - }; - let response = if first_poll { + let poll_id = "cli:agent:start"; + let mut response = if first_poll { first_poll = false; - resolve_agent_target(lookup_target, poll_id)? + resolve_agent_target(name, poll_id)? } else { - resolve_agent_target_unchecked(lookup_target, poll_id)? + resolve_agent_target_unchecked(name, poll_id)? }; if response.get("error").is_some() { - if matches!(mode, AgentWaitMode::Start { .. }) { - return Ok(Err(cli_agent_error( - "cli:agent:start", - "agent_start_failed", - "agent target disappeared before becoming interactive", - ))); + response = resolve_agent_target_unchecked(fallback_pane_id, poll_id)?; + if response.get("error").is_some() { + std::thread::sleep(Duration::from_millis(100)); + continue; } - return Ok(Err(response)); } let agent = &response["result"]["agent"]; - let status = agent["agent_status"].as_str().unwrap_or("unknown"); - let completed = matches!(status, "idle" | "done" | "blocked"); - if !matches!(mode, AgentWaitMode::Start { .. }) - && agent["name"].as_str() != Some(expected_name) + let outcome = if agent["terminal_id"].as_str() != Some(expected_terminal_id) { + Some(Err(agent_name_lost_error("cli:agent:start", name))) + } else if let Some(actual) = agent["agent"] + .as_str() + .filter(|actual| *actual != expected_kind) { - return Ok(Err(agent_name_lost_error(poll_id, expected_name))); - } - let outcome = match mode { - AgentWaitMode::Start { expected_kind } => { - if let Some(actual) = agent["agent"].as_str() { - if actual != expected_kind { - Some(Err(cli_agent_error( - "cli:agent:start", - "agent_kind_mismatch", - format!("expected {expected_kind}, detected {actual}"), - ))) - } else if agent["name"].as_str() != Some(expected_name) { - Some(Err(agent_name_lost_error("cli:agent:start", expected_name))) - } else if completed && agent["interactive_ready"].as_bool().unwrap_or(false) { - Some(Ok(agent.clone())) - } else if !agent["launch_pending"].as_bool().unwrap_or(false) { - Some(Err(cli_agent_error( - "cli:agent:start", - "agent_start_failed", - "agent process exited before becoming interactive", - ))) - } else { - None - } - } else if !agent["launch_pending"].as_bool().unwrap_or(false) { - Some(Err(cli_agent_error( - "cli:agent:start", - "agent_start_failed", - "agent process exited before becoming interactive", - ))) - } else { - None - } - } - AgentWaitMode::Current => { - if completed { - Some(Ok(agent.clone())) - } else if status == "unknown" { - Some(Err(cli_agent_error( - "cli:agent:wait", - "agent_not_running", - "agent is no longer running", - ))) - } else { - None - } - } - AgentWaitMode::AfterPrompt { - baseline_state_change_seq, - } => { - let sequence = agent["state_change_seq"].as_u64().unwrap_or(0); - if sequence > baseline_state_change_seq && completed { - Some(Ok(agent.clone())) - } else if status == "unknown" { - Some(Err(cli_agent_error( - "cli:agent:prompt", - "agent_not_running", - "agent exited while waiting for the prompt", - ))) - } else { - None - } - } + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_kind_mismatch", + format!("expected {expected_kind}, detected {actual}"), + ))) + } else if agent["name"].as_str() != Some(name) { + Some(Err(agent_name_lost_error("cli:agent:start", name))) + } else if agent["interactive_ready"].as_bool().unwrap_or(false) { + Some(Ok(agent.clone())) + } else if !agent["launch_pending"].as_bool().unwrap_or(false) { + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent process exited before becoming interactive", + ))) + } else { + None }; if let Some(outcome) = outcome { return Ok(outcome); @@ -659,15 +580,12 @@ fn print_agent_transport_error( super::print_response(&cli_agent_error(request_id, code, err.to_string())) } -fn agent_wait_timeout(mode: AgentWaitMode<'_>) -> serde_json::Value { - let (id, message) = match mode { - AgentWaitMode::Start { .. } => ("cli:agent:start", "timed out waiting for agent startup"), - AgentWaitMode::Current => ("cli:agent:wait", "timed out waiting for agent completion"), - AgentWaitMode::AfterPrompt { .. } => { - ("cli:agent:prompt", "timed out waiting for prompted work") - } - }; - cli_agent_error(id, "timeout", message) +fn agent_wait_timeout() -> serde_json::Value { + cli_agent_error( + "cli:agent:start", + "timeout", + "timed out waiting for agent startup", + ) } fn cli_agent_error(id: &str, code: &str, message: impl Into<String>) -> serde_json::Value { @@ -698,18 +616,14 @@ fn agent_get_request(target: &str, request_id: &str) -> Request { } fn agent_rename(args: &[String]) -> std::io::Result<i32> { - let Some(target) = args.first() else { + let [target, value] = args else { eprintln!("usage: herdr agent rename <target> <name>|--clear"); return Ok(2); }; - if args.len() < 2 { - eprintln!("usage: herdr agent rename <target> <name>|--clear"); - return Ok(2); - } - let name = if args.len() == 2 && args[1] == "--clear" { + let name = if value == "--clear" { None } else { - Some(args[1..].join(" ")) + Some(value.clone()) }; super::print_response(&super::send_request(&Request { @@ -723,7 +637,9 @@ fn agent_rename(args: &[String]) -> std::io::Result<i32> { fn agent_prompt(args: &[String]) -> std::io::Result<i32> { let Some(target) = args.first() else { - eprintln!("usage: herdr agent prompt <name> <text> [--wait] [--timeout MS]"); + eprintln!( + "usage: herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]" + ); return Ok(2); }; let Some(text) = args.get(1) else { @@ -731,6 +647,7 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> { return Ok(2); }; let mut wait = false; + let mut until = Vec::new(); let mut timeout_ms = None; let mut index = 2; while index < args.len() { @@ -739,12 +656,30 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> { wait = true; index += 1; } + "--until" => { + let Some(value) = args.get(index + 1) else { + eprintln!("--until requires at least one status"); + return Ok(2); + }; + let status = match super::parse_agent_status(value) { + Ok(status) => status, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + until.push(status); + index += 2; + } "--timeout" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --timeout"); return Ok(2); }; - timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + timeout_ms = match parse_timeout(value) { + Ok(timeout_ms) => Some(timeout_ms), + Err(exit_code) => return Ok(exit_code), + }; index += 2; } option => { @@ -753,63 +688,36 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> { } } } - let mut response = super::send_request(&Request { + if !until.is_empty() && !wait { + eprintln!("--until requires --wait"); + return Ok(2); + } + if timeout_ms.is_some() && !wait { + eprintln!("--timeout requires --wait"); + return Ok(2); + } + let response = super::send_request(&Request { id: "cli:agent:prompt".into(), method: Method::AgentPrompt(AgentPromptParams { target: target.clone(), text: text.clone(), + wait: wait.then_some(AgentPromptWaitOptions { until, timeout_ms }), }), })?; - if response.get("error").is_some() || !wait { - return super::print_response(&response); - } - let baseline_state_change_seq = response["result"]["baseline_state_change_seq"] - .as_u64() - .unwrap_or(0); - let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { - return super::print_response(&cli_agent_error( - "cli:agent:prompt", - "agent_prompt_failed", - "agent prompt response did not include terminal_id", - )); - }; - let waited = match wait_for_named_agent( - terminal_id, - target, - timeout_ms.map(Duration::from_millis), - AgentWaitMode::AfterPrompt { - baseline_state_change_seq, - }, - ) { - Ok(waited) => waited, - Err(err) => { - return print_agent_transport_error( - err, - "cli:agent:prompt", - "agent_prompt_transport_failed", - ) - } - }; - match waited { - Ok(agent) => { - response["result"]["agent"] = agent; - super::print_response(&response) - } - Err(error) => super::print_response(&error), - } + super::print_response(&response) } -fn agent_send(args: &[String]) -> std::io::Result<i32> { +fn agent_send_keys(args: &[String]) -> std::io::Result<i32> { if args.len() < 2 { - eprintln!("usage: herdr agent send <target> <text>"); + eprintln!("usage: herdr agent send-keys <target> <key> [key ...]"); return Ok(2); } super::print_response(&super::send_request(&Request { - id: "cli:agent:send".into(), - method: Method::AgentSend(AgentSendParams { + id: "cli:agent:send-keys".into(), + method: Method::AgentSendKeys(AgentSendKeysParams { target: args[0].clone(), - text: args[1..].join(" "), + keys: args[1..].to_vec(), }), })?) } @@ -865,7 +773,7 @@ fn agent_read(args: &[String]) -> std::io::Result<i32> { } } - super::print_response(&super::send_request(&Request { + let response = super::send_request(&Request { id: "cli:agent:read".into(), method: Method::AgentRead(AgentReadParams { target: target.clone(), @@ -874,27 +782,35 @@ fn agent_read(args: &[String]) -> std::io::Result<i32> { format, strip_ansi, }), - })?) + })?; + super::print_read_response(&response) } fn print_agent_help() { eprintln!("herdr agent commands:"); eprintln!(" herdr agent list"); eprintln!(" herdr agent get <target>"); - eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); - eprintln!(" herdr agent send <target> <text>"); - eprintln!(" herdr agent prompt <name> <text> [--wait] [--timeout MS]"); + eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi]"); + eprintln!(" herdr agent send-keys <target> <key> [key ...]"); + eprintln!(" herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]"); eprintln!(" herdr agent rename <target> <name>|--clear"); eprintln!(" herdr agent focus <target>"); - eprintln!(" herdr agent wait <name> [--timeout MS]"); + eprintln!(" herdr agent wait <target> [--until STATUS]... [--timeout MS]"); eprintln!(" herdr agent attach <target> [--takeover]"); eprintln!( " herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]" ); - eprintln!(" herdr agent explain <target> [--json]"); - eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]"); - eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids"); + eprintln!(" herdr agent explain <target> [--json|--format text|json] [--verbose]"); eprintln!( - " agent send writes literal text; use pane run when you want command text plus Enter" + " herdr agent explain --file PATH --agent LABEL [--json|--format text|json] [--verbose]" ); + eprintln!(" targets accept unique agent names and pane ids that currently host agents"); + eprintln!(" kinds: {}", super::spec::agent_kind_values().join("|")); +} + +fn parse_timeout(value: &str) -> Result<u64, i32> { + super::parse_u64_flag("--timeout", value).map_err(|err| { + eprintln!("{err}"); + 2 + }) } diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 80e10c69..4448840b 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1,11 +1,11 @@ use crate::api::schema::{ - Method, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, - PaneLayoutParams, PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams, - PaneProcessInfoParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams, - PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, - PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, - PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, - SplitDirection, + Method, OutputMatch, PaneCurrentParams, PaneDirection, PaneEdgesParams, + PaneFocusDirectionParams, PaneLayoutParams, PaneListParams, PaneMoveDestination, + PaneMoveParams, PaneNeighborParams, PaneProcessInfoParams, PaneReadParams, + PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, + PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, + PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneWaitForOutputParams, + PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, SplitDirection, }; pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { @@ -33,6 +33,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "close" => pane_close(&args[1..]), "send-text" => pane_send_text(&args[1..]), "send-keys" => pane_send_keys(&args[1..]), + "wait-output" => pane_wait_output(&args[1..]), "report-agent" => pane_report_agent(&args[1..]), "report-agent-session" => pane_report_agent_session(&args[1..]), "release-agent" => pane_release_agent(&args[1..]), @@ -505,15 +506,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> { }), })?; - if let Some(error) = response.get("error") { - eprintln!("{}", serde_json::to_string(error).unwrap()); - return Ok(1); - } - - if let Some(text) = response["result"]["read"]["text"].as_str() { - print!("{text}"); - } - Ok(0) + super::print_read_response(&response) } fn pane_split(args: &[String]) -> std::io::Result<i32> { @@ -941,6 +934,92 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> { })) } +fn pane_wait_output(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]"); + return Ok(2); + }; + let pane_id = super::normalize_pane_id(raw_pane_id); + let mut source = ReadSource::Recent; + let mut lines = None; + let mut timeout_ms = None; + let mut strip_ansi = true; + let mut matcher = None; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--match" | "--regex" => { + let option = args[index].as_str(); + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for {option}"); + return Ok(2); + }; + if matcher.is_some() { + eprintln!("--match and --regex are mutually exclusive"); + return Ok(2); + } + matcher = Some(if option == "--regex" { + OutputMatch::Regex { + value: value.clone(), + } + } else { + OutputMatch::Substring { + value: value.clone(), + } + }); + index += 2; + } + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = super::parse_read_source(value)?; + index += 2; + } + "--lines" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --lines"); + return Ok(2); + }; + lines = Some(super::parse_u32_flag("--lines", value)?); + index += 2; + } + "--timeout" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --timeout"); + return Ok(2); + }; + timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + index += 2; + } + "--raw" => { + strip_ansi = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + let Some(matcher) = matcher else { + eprintln!("missing required --match or --regex"); + return Ok(2); + }; + super::print_response(&super::send_request(&Request { + id: "cli:pane:wait-output".into(), + method: Method::PaneWaitForOutput(PaneWaitForOutputParams { + pane_id, + source, + lines, + r#match: matcher, + timeout_ms, + strip_ansi, + }), + })?) +} + fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { eprintln!("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]"); @@ -1430,6 +1509,7 @@ fn print_pane_help() { eprintln!(" herdr pane close <pane_id>"); eprintln!(" herdr pane send-text <pane_id> <text>"); eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); + eprintln!(" herdr pane wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]"); eprintln!(" 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]"); eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]"); diff --git a/src/cli/protocol_guard.rs b/src/cli/protocol_guard.rs index 21302bbf..db12b6f8 100644 --- a/src/cli/protocol_guard.rs +++ b/src/cli/protocol_guard.rs @@ -64,13 +64,13 @@ mod tests { #[test] fn older_server_error_preserves_request_id_and_guidance() { let response = mismatch_response( - "cli:wait:agent-status", + "cli:agent:wait", crate::protocol::PROTOCOL_VERSION - 1, "Run the session stop command, then restart.", ) .unwrap(); - assert_eq!(response.id, "cli:wait:agent-status"); + assert_eq!(response.id, "cli:agent:wait"); assert_eq!(response.error.code, "protocol_mismatch"); assert!(response.error.message.contains(&format!( "client protocol {}", diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 31cd834f..7b2977ac 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -1,4 +1,6 @@ -use clap::{Arg, ArgAction, Command, ValueHint}; +use std::io::Write; + +use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; pub(super) fn command() -> Command { let command = Command::new("herdr") @@ -36,18 +38,64 @@ pub(super) fn command() -> Command { .subcommand(notification_command()) .subcommand(agent_command()) .subcommand(pane_command()) - .subcommand(wait_command()) .subcommand(terminal_command()) .subcommand(session_command()) .subcommand(integration_command()) .subcommand(plugin_command()); - disable_auto_help(command) + configure_help(command, true) } -fn disable_auto_help(command: Command) -> Command { +fn configure_help(command: Command, root: bool) -> Command { + let command = if root { + command + } else { + command.disable_help_flag(false) + }; command - .disable_help_flag(true) - .mut_subcommands(disable_auto_help) + .disable_help_subcommand(true) + .mut_subcommands(|subcommand| configure_help(subcommand, false)) +} + +pub(super) fn print_requested_help(args: &[String]) -> std::io::Result<bool> { + let mut stdout = std::io::stdout().lock(); + write_requested_help(args, &mut stdout) +} + +fn write_requested_help(args: &[String], output: &mut impl Write) -> std::io::Result<bool> { + let Some(help_index) = args + .iter() + .position(|arg| matches!(arg.as_str(), "--help" | "-h")) + else { + return Ok(false); + }; + if help_index < 2 { + return Ok(false); + } + if args[1..help_index].iter().any(|arg| arg == "--") { + return Ok(false); + } + + let mut root = command(); + root.build(); + let mut selected = &mut root; + let mut path = vec!["herdr".to_string()]; + for segment in &args[1..help_index] { + if selected.find_subcommand(segment).is_none() { + break; + } + path.push(segment.clone()); + selected = selected + .find_subcommand_mut(segment) + .expect("subcommand checked immediately before mutable lookup"); + } + if path.len() == 1 || help_index != path.len() { + return Ok(false); + } + + selected.set_bin_name(path.join(" ")); + selected.write_long_help(&mut *output)?; + writeln!(output)?; + Ok(true) } fn completion_command() -> Command { @@ -164,7 +212,7 @@ fn workspace_command() -> Command { Command::new("report-metadata") .about("Report display-only workspace metadata") .arg(required("workspace_id", "WORKSPACE_ID")) - .arg(option("source", "ID")) + .arg(option("source", "ID").required(true)) .arg(repeatable_option("token", "NAME=VALUE")) .arg(repeatable_option("clear-token", "NAME")) .arg(option("seq", "N")) @@ -279,32 +327,65 @@ fn agent_command() -> Command { .arg(flag("ansi")), ) .subcommand( - Command::new("send") - .about("Send text to an agent") + Command::new("send-keys") + .about("Send key presses to an agent") .arg(required("target", "TARGET")) - .arg(required("text", "TEXT")), + .arg(required("key", "KEY").num_args(1..)) + .after_help("Use esc as the canonical Escape key name; escape is also accepted."), ) .subcommand( Command::new("prompt") - .about("Submit a prompt to a named agent") - .arg(required("name", "NAME")) + .about("Submit a prompt to an agent") + .arg(required("target", "TARGET")) .arg(required("text", "TEXT")) - .arg(flag("wait")) - .arg(option("timeout", "MS")), + .arg( + flag("wait") + .help("Wait for the first matching state observed after submission"), + ) + .arg( + option("until", "STATUS") + .action(ArgAction::Append) + .requires("wait") + .value_parser(["idle", "working", "blocked", "done", "unknown"]) + .help("State to match after --wait; repeat for more than one state"), + ) + .arg( + option("timeout", "MS") + .requires("wait") + .help("Fail after this many milliseconds"), + ) + .after_help( + "Without --until, --wait matches the first idle, done, or blocked state observed after submission. It does not track turns: if the agent is already working, that active turn's completion may match. Use --until unknown explicitly when needed. Without --timeout, it waits indefinitely.", + ), ) .subcommand( Command::new("rename") .about("Rename an agent") + .override_usage("herdr agent rename <TARGET> <NAME>|--clear") .arg(required("target", "TARGET")) .arg(Arg::new("name").value_name("NAME")) - .arg(flag("clear")), + .arg(flag("clear")) + .group( + ArgGroup::new("rename") + .args(["name", "clear"]) + .required(true), + ), ) .subcommand(id_command("focus", "target", "Focus an agent")) .subcommand( Command::new("wait") - .about("Wait until a named agent is no longer working") - .arg(required("name", "NAME")) - .arg(option("timeout", "MS")), + .about("Wait until an agent reaches one of the requested states") + .arg(required("target", "TARGET")) + .arg( + option("until", "STATUS") + .action(ArgAction::Append) + .value_parser(["idle", "working", "blocked", "done", "unknown"]) + .help("State to match; repeat for more than one state"), + ) + .arg(option("timeout", "MS").help("Fail after this many milliseconds")) + .after_help( + "Without --until, matches idle, done, or blocked. Use --until unknown explicitly when needed. Without --timeout, waits indefinitely.", + ), ) .subcommand( Command::new("attach") @@ -316,14 +397,29 @@ fn agent_command() -> Command { Command::new("start") .about("Start a supported interactive agent in an existing pane") .arg(required("name", "NAME")) - .arg(option("kind", "KIND").required(true)) - .arg(option("pane", "ID").required(true)) - .arg(option("timeout", "MS")) + .arg( + option("kind", "KIND") + .required(true) + .value_parser(agent_kind_values()) + .help("Supported agent kind and canonical executable"), + ) + .arg( + option("pane", "ID") + .required(true) + .help("Existing pane at an interactive shell prompt"), + ) + .arg( + option("timeout", "MS") + .help("Wait for interactive readiness (default: 30000; max: 300000)"), + ) .arg( Arg::new("agent_args") .value_name("AGENT_ARG") .num_args(0..) .last(true), + ) + .after_help( + "The pane must be at its interactive shell prompt. Success means the expected agent was detected in the same terminal and is ready for input.", ), ) .subcommand( @@ -343,6 +439,13 @@ fn agent_command() -> Command { ) } +pub(super) fn agent_kind_values() -> Vec<&'static str> { + crate::detect::Agent::ALL + .into_iter() + .map(crate::detect::agent_label) + .collect() +} + fn pane_command() -> Command { Command::new("pane") .about("Control terminal panes") @@ -370,7 +473,7 @@ fn pane_command() -> Command { .subcommand( Command::new("neighbor") .about("Find a pane neighbor") - .arg(direction_option()) + .arg(required_direction_option()) .args(current_pane_args()), ) .subcommand( @@ -381,13 +484,13 @@ fn pane_command() -> Command { .subcommand( Command::new("focus") .about("Focus a neighboring pane") - .arg(direction_option()) + .arg(required_direction_option()) .args(current_pane_args()), ) .subcommand( Command::new("resize") .about("Resize a pane split") - .arg(direction_option()) + .arg(required_direction_option()) .arg(option("amount", "FLOAT")) .args(current_pane_args()), ) @@ -464,7 +567,37 @@ fn pane_command() -> Command { Command::new("send-keys") .about("Send key presses to a pane") .arg(required("pane_id", "PANE_ID")) - .arg(required("key", "KEY").num_args(1..)), + .arg(required("key", "KEY").num_args(1..)) + .after_help("Use esc as the canonical Escape key name; escape is also accepted."), + ) + .subcommand( + Command::new("wait-output") + .about("Wait for matching pane output") + .arg(required("pane_id", "PANE_ID")) + .arg( + option("match", "TEXT") + .conflicts_with("regex") + .required_unless_present("regex") + .help("Match a literal substring"), + ) + .arg( + option("regex", "PATTERN") + .conflicts_with("match") + .required_unless_present("match") + .help("Match a Rust regular expression"), + ) + .arg(read_source_option(false)) + .arg(option("lines", "N").help("Restrict the searched snapshot to N lines")) + .arg(option("timeout", "MS").help("Fail after this many milliseconds")) + .arg(flag("raw").help("Keep ANSI escape sequences while matching")) + .group( + ArgGroup::new("matcher") + .args(["match", "regex"]) + .required(true), + ) + .after_help( + "The selected snapshot is searched immediately, including existing output, then polled. Without --timeout, this waits indefinitely.", + ), ) .subcommand( Command::new("run") @@ -482,8 +615,8 @@ fn report_agent_command() -> Command { Command::new("report-agent") .about("Report pane agent lifecycle state") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) - .arg(option("agent", "LABEL")) + .arg(option("source", "ID").required(true)) + .arg(option("agent", "LABEL").required(true)) .arg(pane_agent_state_option("state")) .arg(option("message", "TEXT")) .arg(option("seq", "N")) @@ -495,8 +628,8 @@ fn report_agent_session_command() -> Command { Command::new("report-agent-session") .about("Report pane agent session identity") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) - .arg(option("agent", "LABEL")) + .arg(option("source", "ID").required(true)) + .arg(option("agent", "LABEL").required(true)) .arg(option("seq", "N")) .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) @@ -507,8 +640,8 @@ fn release_agent_command() -> Command { Command::new("release-agent") .about("Release pane agent lifecycle authority") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) - .arg(option("agent", "LABEL")) + .arg(option("source", "ID").required(true)) + .arg(option("agent", "LABEL").required(true)) .arg(option("seq", "N")) } @@ -516,7 +649,7 @@ fn report_metadata_command() -> Command { Command::new("report-metadata") .about("Report display-only pane metadata") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) + .arg(option("source", "ID").required(true)) .arg(option("agent", "LABEL")) .arg(option("applies-to-source", "ID")) .arg(option("title", "TEXT")) @@ -531,29 +664,6 @@ fn report_metadata_command() -> Command { .arg(option("ttl-ms", "N")) } -fn wait_command() -> Command { - Command::new("wait") - .about("Wait for pane output or agent state") - .subcommand( - Command::new("output") - .about("Wait for matching pane output") - .arg(required("pane_id", "PANE_ID")) - .arg(option("match", "TEXT")) - .arg(read_source_option(false)) - .arg(option("lines", "N")) - .arg(option("timeout", "MS")) - .arg(flag("regex")) - .arg(flag("raw")), - ) - .subcommand( - Command::new("agent-status") - .about("Wait for pane agent status") - .arg(required("pane_id", "PANE_ID")) - .arg(status_option("status", true)) - .arg(option("timeout", "MS")), - ) -} - fn terminal_command() -> Command { Command::new("terminal") .about("Attach to or observe raw terminal streams") @@ -567,6 +677,14 @@ fn terminal_command() -> Command { Command::new("session") .about("Work with terminal sessions") .subcommand( + Command::new("control") + .about("Control a terminal stream") + .arg(required("target", "TARGET")) + .arg(flag("takeover")) + .arg(option("cols", "N")) + .arg(option("rows", "N")), + ) + .subcommand( Command::new("observe") .about("Observe a terminal stream") .arg(required("target", "TARGET")) @@ -749,10 +867,14 @@ fn integration_target_arg() -> Arg { Arg::new("target") .value_name("TARGET") .required(true) - .value_parser([ - "pi", "omp", "claude", "codex", "copilot", "devin", "droid", "kimi", "opencode", - "kilo", "hermes", "qodercli", "cursor", - ]) + .value_parser(integration_target_values()) +} + +fn integration_target_values() -> Vec<&'static str> { + crate::api::schema::IntegrationTarget::ALL + .into_iter() + .map(crate::integration::integration_target_label) + .collect() } fn id_command(name: &'static str, id: &'static str, about: &'static str) -> Command { @@ -763,14 +885,12 @@ fn direction_option() -> Arg { option("direction", "DIRECTION").value_parser(["left", "right", "up", "down"]) } -fn split_direction_option() -> Arg { - option("direction", "DIRECTION").value_parser(["right", "down"]) +fn required_direction_option() -> Arg { + direction_option().required(true) } -fn status_option(name: &'static str, required: bool) -> Arg { - option(name, "STATUS") - .required(required) - .value_parser(["idle", "working", "blocked", "done", "unknown"]) +fn split_direction_option() -> Arg { + option("direction", "DIRECTION").value_parser(["right", "down"]) } fn pane_agent_state_option(name: &'static str) -> Arg { @@ -785,7 +905,9 @@ fn read_source_option(include_detection: bool) -> Arg { } else { vec!["visible", "recent", "recent-unwrapped"] }; - option("source", "SOURCE").value_parser(values) + option("source", "SOURCE") + .value_parser(values) + .help("Terminal snapshot source (default: recent)") } fn text_ansi_format_option() -> Arg { @@ -843,7 +965,7 @@ fn path_arg(name: &'static str, value_name: &'static str) -> Arg { #[cfg(test)] mod tests { - use clap::Command; + use clap::{Arg, Command}; fn command_path<'a>(cmd: &'a Command, path: &[&str]) -> &'a Command { let mut current = cmd; @@ -874,6 +996,31 @@ mod tests { .any(|arg| arg.get_long() == Some(option)) } + fn option_arg<'a>(cmd: &'a Command, option: &str) -> &'a Arg { + cmd.get_arguments() + .find(|arg| arg.get_long() == Some(option)) + .unwrap_or_else(|| panic!("missing --{option}")) + } + + fn argument<'a>(cmd: &'a Command, id: &str) -> &'a Arg { + cmd.get_arguments() + .find(|arg| arg.get_id() == id) + .unwrap_or_else(|| panic!("missing argument {id}")) + } + + fn collect_subcommand_paths( + cmd: &Command, + path: &mut Vec<String>, + paths: &mut Vec<Vec<String>>, + ) { + for subcommand in cmd.get_subcommands() { + path.push(subcommand.get_name().to_string()); + paths.push(path.clone()); + collect_subcommand_paths(subcommand, path, paths); + path.pop(); + } + } + fn assert_command_descriptions(cmd: &Command, path: &mut Vec<String>) { if !path.is_empty() { assert!( @@ -896,6 +1043,37 @@ mod tests { } #[test] + fn spec_passes_clap_invariants() { + super::command().debug_assert(); + } + + #[test] + fn every_spec_subcommand_renders_short_and_long_help() { + let mut paths = Vec::new(); + collect_subcommand_paths(&super::command(), &mut Vec::new(), &mut paths); + + for path in paths { + for flag in ["-h", "--help"] { + let mut args = vec!["herdr".to_string()]; + args.extend(path.iter().cloned()); + args.push(flag.to_string()); + let mut output = Vec::new(); + assert!( + super::write_requested_help(&args, &mut output).unwrap(), + "help was not handled for herdr {} {flag}", + path.join(" ") + ); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains(&format!("Usage: herdr {}", path.join(" "))), + "unexpected help for herdr {}: {output}", + path.join(" ") + ); + } + } + } + + #[test] fn spec_includes_completion_alias_and_shells() { let cmd = super::command(); let completion = command_path(&cmd, &["completion"]); @@ -916,6 +1094,93 @@ mod tests { } #[test] + fn spec_matches_all_integration_targets() { + let cmd = super::command(); + let install = command_path(&cmd, &["integration", "install"]); + assert_eq!( + argument(install, "target") + .get_value_parser() + .possible_values() + .unwrap() + .map(|value| value.get_name().to_string()) + .collect::<Vec<_>>(), + crate::api::schema::IntegrationTarget::ALL + .map(crate::integration::integration_target_label) + .map(str::to_string) + ); + } + + #[test] + fn spec_marks_runtime_required_options_as_required() { + for (path, options) in [ + (&["workspace", "report-metadata"][..], &["source"][..]), + (&["pane", "neighbor"][..], &["direction"][..]), + (&["pane", "focus"][..], &["direction"][..]), + (&["pane", "resize"][..], &["direction"][..]), + (&["pane", "report-agent"][..], &["source", "agent"][..]), + ( + &["pane", "report-agent-session"][..], + &["source", "agent"][..], + ), + (&["pane", "release-agent"][..], &["source", "agent"][..]), + (&["pane", "report-metadata"][..], &["source"][..]), + ] { + let cmd = command_path(&super::command(), path).clone(); + for option in options { + assert!( + option_arg(&cmd, option).is_required_set(), + "herdr {} --{option} should be required", + path.join(" ") + ); + } + } + } + + #[test] + fn agent_prompt_until_requires_wait() { + let error = super::command() + .try_get_matches_from([ + "herdr", "agent", "prompt", "reviewer", "hello", "--until", "idle", + ]) + .unwrap_err(); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn agent_rename_requires_exactly_one_name_or_clear() { + for valid in [ + &["herdr", "agent", "rename", "reviewer", "worker"][..], + &["herdr", "agent", "rename", "reviewer", "--clear"][..], + ] { + assert!(super::command().try_get_matches_from(valid).is_ok()); + } + for invalid in [ + &["herdr", "agent", "rename", "reviewer"][..], + &["herdr", "agent", "rename", "reviewer", "worker", "--clear"][..], + ] { + assert!(super::command().try_get_matches_from(invalid).is_err()); + } + + let mut help = Vec::new(); + super::write_requested_help( + &[ + "herdr".to_string(), + "agent".to_string(), + "rename".to_string(), + "--help".to_string(), + ], + &mut help, + ) + .unwrap(); + assert!(String::from_utf8(help) + .unwrap() + .contains("Usage: herdr agent rename <TARGET> <NAME>|--clear")); + } + + #[test] fn spec_includes_nested_plugin_pane_open_options() { let cmd = super::command(); let open = command_path(&cmd, &["plugin", "pane", "open"]); @@ -930,10 +1195,38 @@ mod tests { let cmd = super::command(); let wait = command_path(&cmd, &["agent", "wait"]); assert!(!has_option(wait, "status")); + assert_eq!( + option_values(wait, "until"), + ["idle", "working", "blocked", "done", "unknown"] + ); assert!(has_option(wait, "timeout")); } #[test] + fn spec_matches_refactored_agent_and_pane_commands() { + let cmd = super::command(); + assert!(cmd + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "wait")); + + let agent = command_path(&cmd, &["agent"]); + assert!(agent + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "send-keys")); + assert!(agent + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "wait")); + assert!(agent + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "send")); + + let pane = command_path(&cmd, &["pane"]); + assert!(pane + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "wait-output")); + } + + #[test] fn spec_includes_pane_read_raw_flag() { let cmd = super::command(); let pane_read = command_path(&cmd, &["pane", "read"]); @@ -954,6 +1247,12 @@ mod tests { let cmd = super::command(); let agent_start = command_path(&cmd, &["agent", "start"]); assert!(has_option(agent_start, "kind")); + assert_eq!( + option_values(agent_start, "kind"), + crate::detect::Agent::ALL + .map(crate::detect::agent_label) + .map(str::to_string) + ); assert!(has_option(agent_start, "pane")); for legacy in ["cwd", "workspace", "tab", "split", "focus", "env", "argv"] { assert!(!has_option(agent_start, legacy), "legacy option --{legacy}"); @@ -964,17 +1263,18 @@ mod tests { } #[test] - fn zsh_completion_contains_public_commands_and_values() { - let mut cmd = super::command(); - let mut output = Vec::new(); - clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, "herdr", &mut output); - let script = String::from_utf8(output).unwrap(); - assert!(script.contains("#compdef herdr")); - assert!(script.contains("--help")); - assert!(script.contains("'completion:Generate shell completion scripts'")); - assert!(script.contains("bash elvish fish powershell zsh")); - assert!(script.contains("'pane:Control terminal panes'")); - assert!(script.contains("idle working blocked done unknown")); - assert!(!script.contains("live-handoff")); + fn completion_generation_succeeds_for_every_supported_shell() { + for shell in [ + clap_complete::Shell::Bash, + clap_complete::Shell::Elvish, + clap_complete::Shell::Fish, + clap_complete::Shell::PowerShell, + clap_complete::Shell::Zsh, + ] { + let mut cmd = super::command(); + let mut output = Vec::new(); + clap_complete::generate(shell, &mut cmd, "herdr", &mut output); + assert!(!output.is_empty(), "empty {shell:?} completion output"); + } } } diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 53c3213a..b8eb8f60 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -65,7 +65,6 @@ pub enum Agent { } impl Agent { - #[cfg(test)] pub const ALL: [Self; 21] = [ Self::Pi, Self::Claude, diff --git a/src/main.rs b/src/main.rs index 42ec3d01..a73423bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -554,7 +554,6 @@ fn main() -> io::Result<()> { println!(" herdr notification <subcommand> ..."); println!(" herdr agent <subcommand> ..."); println!(" herdr pane <subcommand> ..."); - println!(" herdr wait <subcommand> ..."); println!(" herdr session <subcommand> ..."); println!(" herdr integration <subcommand> ..."); println!(); @@ -613,10 +612,6 @@ fn main() -> io::Result<()> { "Pane control helpers over the socket API", ), ( - "herdr wait <subcommand>", - "Blocking wait helpers over the socket API", - ), - ( "herdr session <subcommand>", "Manage named persistent sessions", ), @@ -676,7 +671,7 @@ fn main() -> io::Result<()> { if arg.starts_with('-') && !known_flags.contains(&arg_name) { eprintln!("unknown option: {arg}"); eprintln!("run 'herdr --help' for usage"); - std::process::exit(1); + std::process::exit(2); } if !arg.starts_with('-') && ![ @@ -690,7 +685,6 @@ fn main() -> io::Result<()> { "workspace", "worktree", "pane", - "wait", "session", "integration", ] @@ -698,7 +692,7 @@ fn main() -> io::Result<()> { { eprintln!("unknown command: {arg}"); eprintln!("run 'herdr --help' for usage"); - std::process::exit(1); + std::process::exit(2); } } diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 2197e2bb..e442ac17 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -501,6 +501,8 @@ fn restore_tab( }; pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore) }; + let restored_agent_session = + restored_terminal_agent_session(saved_agent_session, startup.duplicate_agent_session); let initial_restore_agent = startup .restore_plan .as_ref() @@ -533,11 +535,14 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } + if let Some(session) = restored_agent_session { + terminal.set_persisted_agent_session(session); + } match (saved_agent_name, saved_managed_agent) { (Some(agent_name), Some(agent)) => { terminal.restore_managed_agent(agent_name, agent) } - (Some(agent_name), None) => terminal.set_agent_name(agent_name), + (Some(_), None) => {} (None, _) => {} } if let Some(agent) = initial_restore_agent { @@ -551,12 +556,6 @@ fn restore_tab( std::time::Instant::now(), ); } - if let Some(session) = restored_terminal_agent_session( - saved_agent_session, - startup.duplicate_agent_session, - ) { - terminal.set_persisted_agent_session(session); - } panes.insert(*id, PaneState::new(terminal_id)); terminals.push(terminal); continue; @@ -630,12 +629,16 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } + if let Some(session) = restored_agent_session { + terminal.set_persisted_agent_session(session); + } match (saved_agent_name, saved_managed_agent) { (Some(agent_name), Some(agent)) if was_imported => { terminal.restore_managed_agent(agent_name, agent) } (Some(_), Some(_)) => {} - (Some(agent_name), None) => terminal.set_agent_name(agent_name), + (Some(agent_name), None) if was_imported => terminal.set_agent_name(agent_name), + (Some(_), None) => {} (None, _) => {} } if let Some(agent) = initial_restore_agent { @@ -649,12 +652,6 @@ fn restore_tab( std::time::Instant::now(), ); } - if let Some(session) = restored_terminal_agent_session( - saved_agent_session, - startup.duplicate_agent_session, - ) { - terminal.set_persisted_agent_session(session); - } panes.insert(*id, PaneState::new(terminal_id.clone())); terminal_runtimes.insert(terminal_id, runtime); terminals.push(terminal); @@ -1326,7 +1323,7 @@ mod tests { } #[tokio::test] - async fn restore_with_gapped_public_tab_numbers_keeps_agent_panel_tab_indices() { + async fn cold_restore_with_gapped_public_tab_numbers_drops_unmanaged_agent_name() { let cwd = std::env::current_dir().unwrap(); let pane_snap = |id: &str| { ( @@ -1424,19 +1421,16 @@ mod tests { ); let workspace = workspaces.first().expect("workspace should restore"); - let agent_pane = workspace.tabs[3].root_pane; - let detail = workspace - .pane_details(&terminals) - .into_iter() - .find(|detail| detail.pane_id == agent_pane) - .expect("restored agent pane should be listed"); - assert_eq!(workspace.active_tab, 3); assert_eq!(workspace.tabs[3].number, 5); - assert_eq!(detail.tab_idx, 3); - assert_eq!(detail.agent_label, "planner"); + let agent_pane = workspace.tabs[3].root_pane; let terminal_id = &workspace.tabs[3].panes[&agent_pane].attached_terminal_id; + assert!(terminals[terminal_id].agent_name.is_none()); assert_eq!(terminals[terminal_id].managed_agent_kind(), None); + assert!(workspace + .pane_details(&terminals) + .into_iter() + .all(|detail| detail.pane_id != agent_pane)); } #[test] diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 225d609a..27ac8528 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -237,6 +237,7 @@ impl TerminalState { Some(TerminalStateMutation { effective_state_change, session_ref_changed: false, + agent_released: false, }) } pub fn effective_title(&self) -> Option<String> { @@ -319,6 +320,7 @@ impl TerminalState { now, ), session_ref_changed: false, + agent_released: false, }) } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 815a8b80..38e4739e 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -49,6 +49,7 @@ enum ManagedAgentPhase { Pending { ready_after: Option<Instant>, deadline: Instant, + observed_expected: bool, }, Active, } @@ -56,7 +57,6 @@ enum ManagedAgentPhase { #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ManagedAgent { kind: Agent, - observed_expected: bool, phase: ManagedAgentPhase, } @@ -82,6 +82,13 @@ pub(crate) struct TerminalTitleChange { pub struct TerminalStateMutation { pub effective_state_change: Option<EffectiveStateChange>, pub session_ref_changed: bool, + pub agent_released: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentNameOwner { + agent_label: String, + session_ref: Option<crate::agent_resume::AgentSessionRef>, } /// Pure state for a server-owned terminal. @@ -103,6 +110,7 @@ pub struct TerminalState { pub terminal_title: Option<String>, pub manual_label: Option<String>, pub agent_name: Option<String>, + agent_name_owner: Option<AgentNameOwner>, managed_agent: Option<ManagedAgent>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, @@ -134,6 +142,7 @@ impl TerminalState { terminal_title: None, manual_label: None, agent_name: None, + agent_name_owner: None, managed_agent: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), @@ -259,6 +268,9 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_detected_agent = self.detected_agent; let previous_session = self.current_session_identity_for_persistence(); + let agent_released = process_exited + && self.hook_authority_not_newer_than(now) + && (previous_agent_label.is_some() || self.agent_name.is_some()); if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) { if self .hook_authority @@ -278,6 +290,7 @@ impl TerminalState { ), session_ref_changed: previous_session != self.current_session_identity_for_persistence(), + agent_released: false, }; } if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) { @@ -291,9 +304,14 @@ impl TerminalState { ), session_ref_changed: previous_session != self.current_session_identity_for_persistence(), + agent_released: false, }; } self.detected_agent = agent; + if let Some(agent) = agent { + let agent_label = crate::detect::agent_label(agent); + self.reconcile_agent_name_owner(agent_label, None); + } if !process_exited { self.clear_full_lifecycle_hook_suppression_for_detected_agent( previous_detected_agent, @@ -358,6 +376,9 @@ impl TerminalState { self.hook_authority = None; self.persisted_agent_session = durable_session; } + if agent_released { + self.clear_agent_name(); + } TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, @@ -368,6 +389,7 @@ impl TerminalState { ), session_ref_changed: previous_session != self.current_session_identity_for_persistence(), + agent_released, } } @@ -486,6 +508,7 @@ impl TerminalState { 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.reconcile_agent_name_owner(&agent_label, session_ref.as_ref()); if foreground_takeover_allowed { self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, @@ -521,6 +544,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, + agent_released: false, }) } @@ -1069,6 +1093,7 @@ impl TerminalState { ); self.hook_authority = None; } + self.reconcile_agent_name_owner(&agent_label, Some(&session_ref)); self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession { source, agent: agent_label, @@ -1084,6 +1109,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, + agent_released: false, }) } @@ -1200,6 +1226,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session.is_some(), + agent_released: false, }) } @@ -1256,6 +1283,7 @@ impl TerminalState { self.fallback_visible_blocker = false; self.fallback_observed_at = None; self.hook_authority = None; + self.clear_agent_name(); if !preserve_foreign_persisted_session { self.persisted_agent_session = None; } @@ -1269,6 +1297,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, + agent_released: true, }) } @@ -1286,6 +1315,23 @@ impl TerminalState { self.detected_agent } + pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange { + let agent_label = self.effective_agent_label().map(str::to_string); + let known_agent = self.effective_known_agent(); + let state = self.state; + let presentation = self.effective_presentation_for_state_at(state, now); + EffectiveStateChange { + previous_agent_label: agent_label.clone(), + previous_known_agent: known_agent, + previous_state: state, + previous_presentation: presentation.clone(), + agent_label, + known_agent, + state, + presentation, + } + } + pub fn full_lifecycle_hook_authority_active(&self) -> bool { self.live_full_lifecycle_hook_authority() } @@ -1319,8 +1365,30 @@ impl TerminalState { } pub fn set_agent_name(&mut self, name: String) { - let name = name.trim().to_string(); self.agent_name = (!name.is_empty()).then_some(name); + self.agent_name_owner = self.agent_name.as_ref().and_then(|_| { + self.hook_authority + .as_ref() + .map(|authority| AgentNameOwner { + agent_label: authority.agent_label.clone(), + session_ref: authority.session_ref.clone(), + }) + .or_else(|| { + self.persisted_agent_session + .as_ref() + .map(|session| AgentNameOwner { + agent_label: session.agent.clone(), + session_ref: Some(session.session_ref.clone()), + }) + }) + .or_else(|| { + self.effective_agent_label() + .map(|agent_label| AgentNameOwner { + agent_label: agent_label.to_string(), + session_ref: None, + }) + }) + }); } pub fn begin_managed_agent( @@ -1332,12 +1400,16 @@ impl TerminalState { timeout: Duration, ) { self.set_agent_name(name); + self.agent_name_owner = Some(AgentNameOwner { + agent_label: crate::detect::agent_label(kind).to_string(), + session_ref: None, + }); self.managed_agent = Some(ManagedAgent { kind, - observed_expected: false, phase: ManagedAgentPhase::Pending { ready_after: Some(now.checked_add(settle_delay).unwrap_or(now)), deadline: now.checked_add(timeout).unwrap_or(now), + observed_expected: false, }, }); } @@ -1348,11 +1420,8 @@ impl TerminalState { } pub fn managed_agent_interactive_ready(&self) -> bool { - self.managed_agent.is_some_and(|managed| { - matches!(managed.phase, ManagedAgentPhase::Active) - && self.effective_known_agent() == Some(managed.kind) - && matches!(self.state, AgentState::Idle | AgentState::Blocked) - }) + self.managed_agent + .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) } pub fn managed_agent_kind(&self) -> Option<Agent> { @@ -1363,6 +1432,7 @@ impl TerminalState { let ManagedAgentPhase::Pending { ready_after, deadline, + .. } = self.managed_agent?.phase else { return None; @@ -1375,25 +1445,29 @@ impl TerminalState { return false; }; let known_agent = self.effective_known_agent(); - let observed_expected = managed.observed_expected || known_agent == Some(managed.kind); + let observed_expected = match managed.phase { + ManagedAgentPhase::Pending { + observed_expected, .. + } => observed_expected || known_agent == Some(managed.kind), + ManagedAgentPhase::Active => false, + }; let clear = process_exited || known_agent.is_some_and(|agent| agent != managed.kind) || matches!(managed.phase, ManagedAgentPhase::Pending { .. }) && observed_expected && known_agent.is_none(); if clear { - self.managed_agent = None; - self.agent_name = None; + self.clear_agent_name(); return true; } if let ManagedAgentPhase::Pending { ready_after, deadline, + observed_expected: previous_observed_expected, } = managed.phase { if now >= deadline { - self.managed_agent = None; - self.agent_name = None; + self.clear_agent_name(); return true; } if ready_after.is_none_or(|ready_after| now >= ready_after) { @@ -1402,7 +1476,6 @@ impl TerminalState { { self.managed_agent = Some(ManagedAgent { kind: managed.kind, - observed_expected: true, phase: ManagedAgentPhase::Active, }); return true; @@ -1410,19 +1483,23 @@ impl TerminalState { if ready_after.is_some() { self.managed_agent = Some(ManagedAgent { kind: managed.kind, - observed_expected, phase: ManagedAgentPhase::Pending { ready_after: None, deadline, + observed_expected, }, }); return true; } } - if observed_expected != managed.observed_expected { + if observed_expected != previous_observed_expected { self.managed_agent = Some(ManagedAgent { - observed_expected, - ..managed + kind: managed.kind, + phase: ManagedAgentPhase::Pending { + ready_after, + deadline, + observed_expected, + }, }); return true; } @@ -1432,15 +1509,19 @@ impl TerminalState { pub fn restore_managed_agent(&mut self, name: String, kind: Agent) { self.set_agent_name(name); + self.agent_name_owner = Some(AgentNameOwner { + agent_label: crate::detect::agent_label(kind).to_string(), + session_ref: None, + }); self.managed_agent = Some(ManagedAgent { kind, - observed_expected: true, phase: ManagedAgentPhase::Active, }); } pub fn clear_agent_name(&mut self) { self.agent_name = None; + self.agent_name_owner = None; self.managed_agent = None; } @@ -1464,9 +1545,45 @@ impl TerminalState { } pub fn is_agent_terminal(&self) -> bool { - self.agent_name.is_some() - || self.effective_agent_label().is_some() - || self.launch_argv.is_some() + self.agent_name.is_some() || self.effective_agent_label().is_some() + } + + fn reconcile_agent_name_owner( + &mut self, + agent_label: &str, + session_ref: Option<&crate::agent_resume::AgentSessionRef>, + ) { + if self.agent_name.is_none() { + return; + } + if self.managed_agent.is_some_and(|managed| { + crate::detect::parse_agent_label(agent_label) == Some(managed.kind) + }) { + return; + } + match self.agent_name_owner.as_mut() { + Some(owner) + if owner.agent_label != agent_label + || owner + .session_ref + .as_ref() + .zip(session_ref) + .is_some_and(|(current, incoming)| current != incoming) => + { + self.agent_name = None; + self.agent_name_owner = None; + } + Some(owner) if owner.session_ref.is_none() && session_ref.is_some() => { + owner.session_ref = session_ref.cloned(); + } + None => { + self.agent_name_owner = Some(AgentNameOwner { + agent_label: agent_label.to_string(), + session_ref: session_ref.cloned(), + }) + } + _ => {} + } } pub fn border_label(&self, show_agent_labels: bool) -> Option<String> { @@ -1566,7 +1683,11 @@ mod tests { assert!(terminal.managed_agent_interactive_ready()); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + assert!(terminal.managed_agent_interactive_ready()); + terminal.set_detected_state(None, AgentState::Unknown); + assert!(terminal.managed_agent_interactive_ready()); assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), true)); @@ -3917,6 +4038,70 @@ mod tests { } #[test] + fn pi_session_replacement_clears_the_previous_sessions_alias() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal + .set_agent_session_ref( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::id("pi-old"), + Some(20), + ) + .expect("initial session should be accepted"); + terminal.set_agent_name("reviewer".into()); + + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::id("pi-new"), + Some(21), + Some("new".into()), + ) + .expect("new should replace the session"); + + assert!(terminal.agent_name.is_none()); + } + + #[test] + fn managed_agent_name_survives_native_session_replacement() { + let mut terminal = test_terminal(); + let now = Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::OpenCode, + now, + Duration::ZERO, + Duration::from_secs(1), + ); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + assert!(terminal.reconcile_managed_agent_at(now, false)); + + for (sequence, session) in [(20, "opencode-old"), (21, "opencode-new")] { + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id(session), + Some(sequence), + Some("new".into()), + ) + .expect("managed session should be accepted"); + } + + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-new") + ); + } + + #[test] fn opencode_session_ref_without_start_source_does_not_replace_existing() { let mut terminal = test_terminal(); terminal @@ -4352,6 +4537,118 @@ mod tests { } #[test] + fn agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + + terminal.set_detected_state(None, AgentState::Unknown); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + assert!(terminal.agent_name.is_none()); + + terminal.set_agent_name("replacement".into()); + terminal + .release_agent_with_mutation("herdr:codex", "codex", None) + .expect("detected agent release should be accepted"); + assert!(terminal.agent_name.is_none()); + } + + #[test] + fn agent_replacement_clears_alias_owned_by_hook_identity() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + Some(20), + ); + terminal.set_agent_name("reviewer".into()); + + terminal.set_detected_state(Some(Agent::Grok), AgentState::Idle); + + assert!(terminal.agent_name.is_none()); + assert_eq!(terminal.effective_known_agent(), Some(Agent::Grok)); + } + + #[test] + fn accepted_hook_replacement_clears_the_previous_agents_alias() { + let mut terminal = test_terminal(); + terminal + .set_hook_authority_at( + "custom:agent".into(), + "pi".into(), + AgentState::Working, + None, + None, + Some(20), + Instant::now(), + ) + .expect("initial hook should be accepted"); + terminal.set_agent_name("reviewer".into()); + + terminal + .set_hook_authority_at( + "custom:agent".into(), + "claude".into(), + AgentState::Idle, + None, + None, + Some(21), + Instant::now(), + ) + .expect("replacement hook should be accepted"); + + assert!(terminal.agent_name.is_none()); + assert_eq!(terminal.effective_known_agent(), Some(Agent::Claude)); + } + + #[test] + fn accepted_same_kind_hook_owner_replacement_clears_the_alias() { + let mut terminal = test_terminal(); + terminal + .set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")), + Some(20), + Instant::now(), + ) + .expect("initial hook should be accepted"); + terminal.set_agent_name("reviewer".into()); + terminal + .clear_hook_authority_with_mutation(Some("herdr:pi"), Some(21)) + .expect("hook clear should be accepted"); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal + .set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("second.jsonl")), + Some(22), + Instant::now(), + ) + .expect("replacement hook should be accepted"); + + assert!(terminal.agent_name.is_none()); + assert_eq!(terminal.effective_known_agent(), Some(Agent::Pi)); + } + + #[test] + fn launch_command_alone_does_not_make_a_terminal_an_agent() { + let terminal = test_terminal().with_launch_argv(vec!["just".into(), "dev".into()]); + + assert!(!terminal.is_agent_terminal()); + } + + #[test] fn release_agent_clears_matching_restored_session_ref_before_detection() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 0e0b711e..a31a5866 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -509,6 +509,21 @@ fn workspace_list_and_create_round_trip() { let recent_text = recent["result"]["read"]["text"].as_str().unwrap(); assert!(recent_text.contains("beta") || recent_text.contains("gamma")); + for source in ["visible", "detection"] { + let limited = send_request( + &socket_path, + &format!( + r#"{{"id":"req_11_{source}","method":"pane.read","params":{{"pane_id":"{}","source":"{source}","lines":2}}}}"#, + pane_id + ), + ); + let text = limited["result"]["read"]["text"].as_str().unwrap(); + assert!( + text.lines().count() <= 2, + "{source} ignored its two-line limit: {text:?}" + ); + } + let waited = send_request( &socket_path, &format!( @@ -1176,29 +1191,32 @@ fn agent_methods_round_trip_over_socket() { assert_eq!(agents[0]["agent_status"], "working"); assert_eq!(agents[0]["pane_id"], pane_id); - let fetched_by_detected_agent = send_request( + let fetched_by_pane = send_request( &socket_path, - r#"{"id":"agent_get_detected","method":"agent.get","params":{"target":"pi"}}"#, + &format!( + r#"{{"id":"agent_get_pane","method":"agent.get","params":{{"target":"{}"}}}}"#, + pane_id + ), ); assert_eq!( - fetched_by_detected_agent["result"]["agent"]["terminal_id"], + fetched_by_pane["result"]["agent"]["terminal_id"], terminal_id ); let renamed_first_agent = send_request( &socket_path, - r#"{"id":"agent_rename_first","method":"agent.rename","params":{"target":"pi","name":"worker"}}"#, + &format!( + r#"{{"id":"agent_rename_first","method":"agent.rename","params":{{"target":"{}","name":"worker"}}}}"#, + pane_id + ), ); assert_eq!(renamed_first_agent["result"]["agent"]["name"], "worker"); - let fetched_by_terminal = send_request( + let fetched_by_name = send_request( &socket_path, - &format!( - r#"{{"id":"agent_get_terminal","method":"agent.get","params":{{"target":"{}"}}}}"#, - terminal_id - ), + r#"{"id":"agent_get_name","method":"agent.get","params":{"target":"worker"}}"#, ); - assert_eq!(fetched_by_terminal["result"]["agent"]["name"], "worker"); + assert_eq!(fetched_by_name["result"]["agent"]["name"], "worker"); let read = send_request( &socket_path, @@ -1209,12 +1227,9 @@ fn agent_methods_round_trip_over_socket() { let sent = send_request( &socket_path, - &format!( - r#"{{"id":"agent_send","method":"agent.send","params":{{"target":"{}","text":"echo agent-send-ok\n"}}}}"#, - terminal_id - ), + r#"{"id":"agent_send_keys","method":"agent.send_keys","params":{"target":"worker","keys":["enter"]}}"#, ); - assert_eq!(sent["result"]["type"], "ok"); + assert_eq!(sent["error"]["code"], "agent_not_ready"); let tab_created = send_request( &socket_path, @@ -1244,7 +1259,7 @@ fn agent_methods_round_trip_over_socket() { &socket_path, &format!( r#"{{"id":"agent_second_rename","method":"agent.rename","params":{{"target":"{}","name":"reviewer"}}}}"#, - second_terminal_id + second_pane_id ), ); assert_eq!(second_renamed["result"]["agent"]["name"], "reviewer"); @@ -1785,7 +1800,10 @@ fn pane_report_agent_updates_effective_state() { let agent = send_request( &socket_path, - r#"{"id":"req_hook_metadata_agent","method":"agent.get","params":{"target":"pi"}}"#, + &format!( + r#"{{"id":"req_hook_metadata_agent","method":"agent.get","params":{{"target":"{}"}}}}"#, + pane_id + ), ); assert_eq!(agent["result"]["agent"]["agent"], "pi"); assert_eq!( diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 00000000..c7eb2834 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,6 @@ +#![cfg(not(target_os = "macos"))] + +mod support; + +#[path = "cli/mod.rs"] +mod cases; diff --git a/tests/cli/agent_options.rs b/tests/cli/agent_options.rs new file mode 100644 index 00000000..f8b6b7af --- /dev/null +++ b/tests/cli/agent_options.rs @@ -0,0 +1,83 @@ +use super::harness::*; + +#[test] +fn agent_wait_accepts_repeated_until_and_exits_when_one_status_matches() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let bin_dir = base.join("bin"); + + fs::create_dir_all(&bin_dir).unwrap(); + let fake_pi = bin_dir.join("pi"); + fs::write( + &fake_pi, + "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&fake_pi).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&fake_pi, perms).unwrap(); + } + + let inherited_path = std::env::var("PATH").unwrap_or_default(); + let path_override = format!("{}:{}", bin_dir.display(), inherited_path); + let herdr = spawn_herdr_with_path( + &config_home, + &runtime_dir, + &socket_path, + Some(Path::new(&path_override)), + ); + + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_2","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let start_pi = run_cli(&socket_path, &["pane", "run", &pane_id, "pi"]); + assert!(start_pi.status.success()); + assert!(wait_until( + Duration::from_secs(3), + Duration::from_millis(25), + || run_cli(&socket_path, &["agent", "get", &pane_id]) + .status + .success() + )); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + &pane_id, + "--until", + "blocked", + "--until", + "idle", + "--timeout", + "10000", + ], + ); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited_json["result"]["agent"]["agent_status"], "idle"); + assert_eq!(waited_json["result"]["agent"]["agent"], "pi"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/agent_transport.rs b/tests/cli/agent_transport.rs new file mode 100644 index 00000000..42e67828 --- /dev/null +++ b/tests/cli/agent_transport.rs @@ -0,0 +1,146 @@ +use super::harness::*; + +#[test] +fn agent_start_accepts_durable_readiness_during_detection_gap() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut start_stream, start_line) = accept_fake_cli_operation(&listener); + let start: serde_json::Value = serde_json::from_str(&start_line).unwrap(); + assert_eq!(start["method"], "agent.start"); + writeln!( + start_stream, + "{}", + serde_json::json!({ + "id": start["id"], + "result": { + "type": "agent_started", + "agent": { + "pane_id": "w1:p1", + "terminal_id": "term_1", + "name": "reviewer" + }, + "argv": ["opencode"] + } + }) + ) + .unwrap(); + start_stream.flush().unwrap(); + + let (mut get_stream, get_line) = accept_fake_cli_operation(&listener); + let get: serde_json::Value = serde_json::from_str(&get_line).unwrap(); + assert_eq!(get["method"], "agent.get"); + assert_eq!(get["params"]["target"], "reviewer"); + writeln!( + get_stream, + "{}", + serde_json::json!({ + "id": get["id"], + "result": { + "type": "agent_info", + "agent": { + "agent": null, + "agent_status": "unknown", + "interactive_ready": true, + "launch_pending": false, + "name": "reviewer", + "pane_id": "w1:p1", + "terminal_id": "term_1" + } + } + }) + ) + .unwrap(); + get_stream.flush().unwrap(); + }); + + let started = run_cli( + &socket_path, + &[ + "agent", "start", "reviewer", "--kind", "opencode", "--pane", "w1:p1", + ], + ); + assert!( + started.status.success(), + "stderr: {}", + String::from_utf8_lossy(&started.stderr) + ); + let started: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap(); + assert_eq!(started["result"]["type"], "agent_started"); + assert_eq!(started["result"]["agent"]["name"], "reviewer"); + assert!(started["result"]["agent"]["interactive_ready"] + .as_bool() + .unwrap()); + + server.join().unwrap(); + cleanup_test_base(&base); +} + +#[test] +fn prompt_wait_is_sent_as_one_agent_request() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut prompt_stream, prompt_line) = accept_fake_cli_operation(&listener); + let prompt: serde_json::Value = serde_json::from_str(&prompt_line).unwrap(); + assert_eq!(prompt["method"], "agent.prompt"); + assert_eq!(prompt["params"]["target"], "w1:p1"); + assert_eq!( + prompt["params"]["wait"]["until"], + serde_json::json!(["idle"]) + ); + assert!(prompt["params"]["wait"].get("timeout_ms").is_none()); + writeln!( + prompt_stream, + "{}", + serde_json::json!({ + "id": prompt["id"], + "result": { + "type": "agent_prompted", + "agent": { + "pane_id": "w1:p1", + "terminal_id": "term_1", + "name": "reviewer", + "agent": "pi", + "agent_status": "idle", + "workspace_id": "w1", + "tab_id": "w1:t1", + "focused": true, + "revision": 0 + } + } + }) + ) + .unwrap(); + prompt_stream.flush().unwrap(); + }); + + let prompted = run_cli( + &socket_path, + &[ + "agent", + "prompt", + "w1:p1", + "review this", + "--wait", + "--until", + "idle", + ], + ); + assert!( + prompted.status.success(), + "stderr: {}", + String::from_utf8_lossy(&prompted.stderr) + ); + let prompted: serde_json::Value = serde_json::from_slice(&prompted.stdout).unwrap(); + assert_eq!(prompted["result"]["agent"]["name"], "reviewer"); + + server.join().unwrap(); + cleanup_test_base(&base); +} diff --git a/tests/cli/agent_wait.rs b/tests/cli/agent_wait.rs new file mode 100644 index 00000000..11997d00 --- /dev/null +++ b/tests/cli/agent_wait.rs @@ -0,0 +1,200 @@ +use super::harness::*; + +#[test] +fn agent_wait_exits_immediately_when_status_already_matches() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_immediate_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let workspace_id = created["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + let pane_id = format!("{workspace_id}:p1"); + + let reported = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#, + pane_id + ), + ); + assert_eq!(reported["result"]["type"], "ok"); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + &pane_id, + "--until", + "idle", + "--timeout", + "1000", + ], + ); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited_json["result"]["agent"]["agent_status"], "idle"); + assert_eq!(waited_json["result"]["agent"]["agent"], "pi"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_times_out_when_status_does_not_match() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_timeout_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + assert_eq!(created["result"]["type"], "workspace_created"); + let pane_id = created["result"]["root_pane"]["pane_id"].as_str().unwrap(); + let reported = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#, + pane_id + ), + ); + assert_eq!(reported["result"]["type"], "ok"); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + pane_id, + "--until", + "blocked", + "--timeout", + "100", + ], + ); + assert!(!waited.status.success()); + assert!( + String::from_utf8_lossy(&waited.stderr).contains("timed out waiting for agent status"), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_exits_when_done_status_matches() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let bin_dir = base.join("bin"); + + fs::create_dir_all(&bin_dir).unwrap(); + let fake_pi = bin_dir.join("pi"); + fs::write( + &fake_pi, + "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&fake_pi).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&fake_pi, perms).unwrap(); + } + + let inherited_path = std::env::var("PATH").unwrap_or_default(); + let path_override = format!("{}:{}", bin_dir.display(), inherited_path); + let herdr = spawn_herdr_with_path( + &config_home, + &runtime_dir, + &socket_path, + Some(Path::new(&path_override)), + ); + + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_status_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let workspace_id = created["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + + let tab_created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_status_2","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#, + workspace_id + ), + ); + assert_eq!(tab_created["result"]["type"], "tab_created"); + + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let start_pi = run_cli(&socket_path, &["pane", "run", &pane_id, "pi"]); + assert!(start_pi.status.success()); + assert!(wait_until( + Duration::from_secs(3), + Duration::from_millis(25), + || run_cli(&socket_path, &["agent", "get", &pane_id]) + .status + .success() + )); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + &pane_id, + "--until", + "done", + "--timeout", + "10000", + ], + ); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited_json["result"]["agent"]["agent_status"], "done"); + assert_eq!(waited_json["result"]["agent"]["agent"], "pi"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/agents.rs b/tests/cli/agents.rs new file mode 100644 index 00000000..e06d0ee5 --- /dev/null +++ b/tests/cli/agents.rs @@ -0,0 +1,1189 @@ +use super::harness::*; + +#[test] +fn agent_start_command_works() { + use std::os::unix::fs::PermissionsExt; + + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let bin = base.join("bin"); + let captured_args = base.join("pi-args"); + let captured_prompts = base.join("pi-prompts"); + fs::create_dir_all(&bin).unwrap(); + let fake_pi = bin.join("pi"); + fs::write( + &fake_pi, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nexport HERDR_AGENT=pi\n'{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\nwhile IFS= read -r prompt; do\n [ \"$prompt\" = \"do not transition\" ] && continue\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state working >/dev/null\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\n printf '%s\\n' \"$prompt\" >> '{}'\ndone\n", + captured_args.display(), + env!("CARGO_BIN_EXE_herdr"), + env!("CARGO_BIN_EXE_herdr"), + env!("CARGO_BIN_EXE_herdr"), + captured_prompts.display(), + ), + ) + .unwrap(); + fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); + + let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + run_cli_json(&socket_path, &["pane", "rename", &pane_id, "shell-pane"]); + let before = run_cli_json(&socket_path, &["pane", "list"]); + let before_topology = pane_topology_snapshot(&before); + + let missing = run_cli( + &socket_path, + &[ + "agent", "start", "missing", "--kind", "pi", "--pane", "w999:p1", + ], + ); + assert_eq!(missing.status.code(), Some(1)); + let missing: serde_json::Value = serde_json::from_slice(&missing.stderr).unwrap(); + assert_eq!(missing["error"]["code"], "agent_pane_not_found"); + assert_eq!( + pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])), + before_topology + ); + + for unsafe_arg in ["tab\tcompletion", "escape\x1b[201~"] { + let rejected = run_cli( + &socket_path, + &[ + "agent", + "start", + "invalid-argument", + "--kind", + "pi", + "--pane", + &pane_id, + "--", + unsafe_arg, + ], + ); + assert_eq!(rejected.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap(); + assert_eq!(error["error"]["code"], "invalid_agent_argument"); + } + + for invalid_timeout in ["3000", "300001"] { + let rejected = run_cli( + &socket_path, + &[ + "agent", + "start", + "invalid-timeout", + "--kind", + "pi", + "--pane", + &pane_id, + "--timeout", + invalid_timeout, + ], + ); + assert_eq!(rejected.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap(); + assert_eq!(error["error"]["code"], "invalid_agent_timeout"); + } + + let started = run_cli_json( + &socket_path, + &[ + "agent", + "start", + "main", + "--kind", + "pi", + "--pane", + &pane_id, + "--timeout", + "8000", + "--", + "--name", + "scratch", + "--no-session", + ], + ); + assert_eq!(started["result"]["type"], "agent_started"); + assert_eq!(started["result"]["agent"]["name"], "main"); + assert_eq!(started["result"]["agent"]["agent"], "pi"); + assert_eq!(started["result"]["agent"]["pane_id"], pane_id); + assert_eq!( + run_cli_json(&socket_path, &["pane", "get", &pane_id])["result"]["pane"]["label"], + "shell-pane" + ); + assert_eq!(started["result"]["argv"][0], "pi"); + assert_eq!(started["result"]["argv"][1], "--name"); + assert_eq!(started["result"]["argv"][2], "scratch"); + assert_eq!(started["result"]["argv"][3], "--no-session"); + assert_eq!( + fs::read_to_string(&captured_args).unwrap(), + "--name\nscratch\n--no-session\n" + ); + + let literal_flag_prompt = run_cli(&socket_path, &["agent", "prompt", "main", "--wait"]); + assert!( + literal_flag_prompt.status.success(), + "flag-shaped prompt was not treated literally: {}", + String::from_utf8_lossy(&literal_flag_prompt.stderr) + ); + let literal_flag_prompt: serde_json::Value = + serde_json::from_slice(&literal_flag_prompt.stdout).unwrap(); + assert_eq!(literal_flag_prompt["result"]["type"], "agent_prompted"); + assert!(wait_until( + Duration::from_secs(2), + Duration::from_millis(25), + || captured_prompts.exists() + )); + + let after = run_cli_json(&socket_path, &["pane", "list"]); + assert_eq!(pane_topology_snapshot(&after), before_topology); + + let stale_idle = run_cli( + &socket_path, + &[ + "agent", + "prompt", + "main", + "do not transition", + "--wait", + "--timeout", + "200", + ], + ); + assert_eq!(stale_idle.status.code(), Some(1)); + let stale_idle: serde_json::Value = serde_json::from_slice(&stale_idle.stderr).unwrap(); + assert_eq!(stale_idle["error"]["code"], "timeout"); + + let prompted = run_cli( + &socket_path, + &[ + "agent", + "prompt", + "main", + "Review this diff", + "--wait", + "--timeout", + "2000", + ], + ); + assert!( + prompted.status.success(), + "prompt failed: {}", + String::from_utf8_lossy(&prompted.stderr) + ); + let prompted: serde_json::Value = serde_json::from_slice(&prompted.stdout).unwrap(); + assert_eq!(prompted["result"]["type"], "agent_prompted"); + + let duplicate = run_cli( + &socket_path, + &["agent", "start", "main", "--kind", "pi", "--pane", &pane_id], + ); + assert!(!duplicate.status.success()); + let duplicate_json: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap(); + assert_eq!(duplicate_json["error"]["code"], "agent_name_taken"); + + let busy = run_cli( + &socket_path, + &[ + "agent", "start", "second", "--kind", "pi", "--pane", &pane_id, + ], + ); + assert!(!busy.status.success()); + let busy_json: serde_json::Value = serde_json::from_slice(&busy.stderr).unwrap(); + assert_eq!(busy_json["error"]["code"], "agent_pane_busy"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_start_rejects_a_shell_replaced_by_a_foreground_program() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let topology = pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])); + assert!( + run_cli(&socket_path, &["pane", "run", &pane_id, "exec sleep 5"]) + .status + .success() + ); + thread::sleep(Duration::from_millis(150)); + + let started = run_cli( + &socket_path, + &[ + "agent", + "start", + "worker", + "--kind", + "pi", + "--pane", + &pane_id, + "--timeout", + "1000", + ], + ); + assert_eq!(started.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); + assert_eq!(error["error"]["code"], "agent_pane_busy"); + assert_eq!( + pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])), + topology + ); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_start_timeout_releases_the_name_for_reuse() { + use std::os::unix::fs::PermissionsExt; + + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let fake_pi = bin.join("pi"); + fs::write( + &fake_pi, + "#!/bin/sh\nunset HERDR_AGENT\nexec /bin/sleep 20\n", + ) + .unwrap(); + fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); + + let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let split = run_cli_json( + &socket_path, + &["pane", "split", &pane_id, "--direction", "right"], + ); + let reuse_pane_id = split["result"]["pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let started = run_cli( + &socket_path, + &[ + "agent", + "start", + "worker", + "--kind", + "pi", + "--pane", + &pane_id, + "--timeout", + "3100", + ], + ); + assert_eq!(started.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); + assert_eq!(error["error"]["code"], "timeout"); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &reuse_pane_id, + "--source", + "custom:reuse", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let reused = run_cli(&socket_path, &["agent", "rename", &reuse_pane_id, "worker"]); + assert!( + reused.status.success(), + "name was not released: {}", + String::from_utf8_lossy(&reused.stderr) + ); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_start_reports_detected_kind_mismatch_before_released_name() { + use std::os::unix::fs::PermissionsExt; + + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let fake_pi = bin.join("pi"); + fs::write( + &fake_pi, + "#!/bin/sh\nHERDR_AGENT=codex exec /bin/sleep 10\n", + ) + .unwrap(); + fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); + + let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let split = run_cli_json( + &socket_path, + &["pane", "split", &pane_id, "--direction", "right"], + ); + let reuse_pane_id = split["result"]["pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let started = run_cli( + &socket_path, + &[ + "agent", + "start", + "worker", + "--kind", + "pi", + "--pane", + &pane_id, + "--timeout", + "5000", + ], + ); + assert_eq!(started.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); + assert_eq!(error["error"]["code"], "agent_kind_mismatch"); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &reuse_pane_id, + "--source", + "custom:reuse", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let reused = run_cli(&socket_path, &["agent", "rename", &reuse_pane_id, "worker"]); + assert!(reused.status.success()); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_start_follows_its_named_terminal_when_the_pane_moves() { + use std::os::unix::fs::PermissionsExt; + + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let fake_pi = bin.join("pi"); + fs::write(&fake_pi, "#!/bin/sh\nHERDR_AGENT=pi exec /bin/sleep 10\n").unwrap(); + fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); + + let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let first = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let start_socket = socket_path.clone(); + let start_pane = first.clone(); + let starter = thread::spawn(move || { + run_cli( + &start_socket, + &[ + "agent", + "start", + "worker", + "--kind", + "pi", + "--pane", + &start_pane, + "--timeout", + "8000", + ], + ) + }); + assert!(wait_until( + Duration::from_secs(2), + Duration::from_millis(25), + || run_cli(&socket_path, &["agent", "get", "worker"]) + .status + .success() + )); + + let moved = run_cli( + &socket_path, + &[ + "pane", + "move", + &first, + "--new-workspace", + "--label", + "moved", + "--no-focus", + ], + ); + assert!( + moved.status.success(), + "move failed: {}", + String::from_utf8_lossy(&moved.stderr) + ); + + let started = starter.join().unwrap(); + assert!( + started.status.success(), + "start failed after swap: {}", + String::from_utf8_lossy(&started.stderr) + ); + let started: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap(); + assert_ne!(started["result"]["agent"]["pane_id"], first); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_start_and_rename_reject_invalid_names() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let expected_message = "agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)"; + + let started = run_cli( + &socket_path, + &[ + "agent", + "start", + "reviewer one", + "--kind", + "pi", + "--pane", + &pane_id, + ], + ); + assert_eq!(started.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); + assert_eq!(error["error"]["code"], "invalid_agent_name"); + assert_eq!(error["error"]["message"], expected_message); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:name", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let renamed = run_cli(&socket_path, &["agent", "rename", &pane_id, "reviewer one"]); + assert_eq!(renamed.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&renamed.stderr).unwrap(); + assert_eq!(error["error"]["code"], "invalid_agent_name"); + assert_eq!(error["error"]["message"], expected_message); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_commands_work() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + let root_pane_id = created_json["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let terminal_id = created_json["result"]["root_pane"]["terminal_id"] + .as_str() + .unwrap() + .to_string(); + + let reported = run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &root_pane_id, + "--source", + "custom:test", + "--agent", + "pi", + "--state", + "idle", + ], + ); + assert!(reported.status.success()); + let renamed = run_cli(&socket_path, &["agent", "rename", &root_pane_id, "worker"]); + assert!(renamed.status.success()); + + let listed = run_cli_json(&socket_path, &["agent", "list"]); + assert_eq!(listed["result"]["type"], "agent_list"); + assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id); + assert_eq!(listed["result"]["agents"][0]["name"], "worker"); + + let fetched = run_cli_json(&socket_path, &["agent", "get", "worker"]); + assert_eq!(fetched["result"]["agent"]["pane_id"], root_pane_id); + let waited = run_cli_json( + &socket_path, + &["agent", "wait", "worker", "--timeout", "100"], + ); + assert_eq!(waited["result"]["agent"]["pane_id"], root_pane_id); + + // A stale semantic report must not allow prompt text into the resumed shell. + let prompted = run_cli( + &socket_path, + &["agent", "prompt", "worker", "echo prompt-must-not-run"], + ); + assert_eq!(prompted.status.code(), Some(1)); + let prompted_json: serde_json::Value = serde_json::from_slice(&prompted.stderr).unwrap(); + assert_eq!(prompted_json["error"]["code"], "agent_not_ready"); + + let working = run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &root_pane_id, + "--source", + "custom:wait", + "--agent", + "pi", + "--state", + "working", + ], + ); + assert!(working.status.success()); + let blocked_socket = socket_path.clone(); + let blocked_pane = root_pane_id.clone(); + let blocked_transition = thread::spawn(move || { + thread::sleep(Duration::from_millis(100)); + let blocked = run_cli( + &blocked_socket, + &[ + "pane", + "report-agent", + &blocked_pane, + "--source", + "custom:wait", + "--agent", + "pi", + "--state", + "blocked", + ], + ); + assert!(blocked.status.success()); + }); + let waited = run_cli_json( + &socket_path, + &["agent", "wait", "worker", "--timeout", "2000"], + ); + blocked_transition.join().unwrap(); + assert_eq!(waited["result"]["agent"]["agent_status"], "blocked"); + let immediate_blocked = + run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]); + assert_eq!( + immediate_blocked["result"]["agent"]["agent_status"], + "blocked" + ); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &root_pane_id, + "--source", + "custom:wait", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + let idle_socket = socket_path.clone(); + let idle_pane = root_pane_id.clone(); + let idle_transition = thread::spawn(move || { + thread::sleep(Duration::from_millis(100)); + assert!(run_cli( + &idle_socket, + &[ + "pane", + "report-agent", + &idle_pane, + "--source", + "custom:wait", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + }); + let idle_wait = run_cli_json( + &socket_path, + &["agent", "wait", "worker", "--timeout", "2000"], + ); + idle_transition.join().unwrap(); + assert!(matches!( + idle_wait["result"]["agent"]["agent_status"].as_str(), + Some("idle" | "done") + )); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &root_pane_id, + "--source", + "custom:wait", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + let timed_out = run_cli( + &socket_path, + &["agent", "wait", "worker", "--timeout", "100"], + ); + assert_eq!(timed_out.status.code(), Some(1)); + let timeout: serde_json::Value = serde_json::from_slice(&timed_out.stderr).unwrap(); + assert_eq!(timeout["error"]["code"], "timeout"); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &root_pane_id, + "--source", + "custom:wait", + "--agent", + "pi", + "--state", + "unknown", + ], + ) + .status + .success()); + let unknown = run_cli_json( + &socket_path, + &[ + "agent", + "wait", + "worker", + "--until", + "unknown", + "--timeout", + "1000", + ], + ); + assert_eq!(unknown["result"]["agent"]["agent_status"], "unknown"); + + let pane_read = run_cli( + &socket_path, + &["pane", "read", &root_pane_id, "--source", "visible"], + ); + let agent_read = run_cli( + &socket_path, + &["agent", "read", &root_pane_id, "--source", "visible"], + ); + assert!(pane_read.status.success()); + assert!(agent_read.status.success()); + assert_eq!(agent_read.stdout, pane_read.stdout); + + let missing_read = run_cli(&socket_path, &["agent", "read", "missing"]); + assert_eq!(missing_read.status.code(), Some(1)); + let missing_read: serde_json::Value = serde_json::from_slice(&missing_read.stderr).unwrap(); + assert_eq!(missing_read["error"]["code"], "agent_not_found"); + + let sent = run_cli(&socket_path, &["agent", "send-keys", "worker", "enter"]); + assert_eq!(sent.status.code(), Some(1)); + let sent: serde_json::Value = serde_json::from_slice(&sent.stderr).unwrap(); + assert_eq!(sent["error"]["code"], "agent_not_ready"); + + let agent_renamed = run_cli_json(&socket_path, &["agent", "rename", "worker", "reviewer"]); + assert_eq!(agent_renamed["result"]["agent"]["name"], "reviewer"); + + let focused = run_cli_json(&socket_path, &["agent", "focus", "reviewer"]); + assert_eq!(focused["result"]["agent"]["focused"], true); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_returns_immediately_for_unseen_done_agent() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let first = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let workspace_id = created["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap(); + let second_tab = run_cli_json( + &socket_path, + &["tab", "create", "--workspace", workspace_id], + ); + let second_tab_id = second_tab["result"]["tab"]["tab_id"].as_str().unwrap(); + assert_ne!(second_tab_id, "w1:t1"); + assert!(run_cli(&socket_path, &["tab", "focus", second_tab_id]) + .status + .success()); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &first, + "--source", + "custom:done", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + assert!( + run_cli(&socket_path, &["agent", "rename", &first, "worker"]) + .status + .success() + ); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &first, + "--source", + "custom:done", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + + let waited = run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]); + assert_eq!(waited["result"]["agent"]["agent_status"], "done"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_tolerates_detection_uncertainty_and_pane_target_rename() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:uncertain", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + assert!( + run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]) + .status + .success() + ); + + let wait_socket = socket_path.clone(); + let waiter = thread::spawn(move || { + run_cli( + &wait_socket, + &[ + "agent", + "wait", + "worker", + "--until", + "unknown", + "--timeout", + "2000", + ], + ) + }); + thread::sleep(Duration::from_millis(150)); + let cleared = send_request( + &socket_path, + &format!( + r#"{{"id":"agent_wait_uncertain","method":"pane.clear_agent_authority","params":{{"pane_id":"{}","source":"custom:uncertain"}}}}"#, + pane_id + ), + ); + assert_eq!(cleared["result"]["type"], "ok"); + let waited = waiter.join().unwrap(); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited["result"]["agent"]["agent_status"], "unknown"); + assert_eq!(waited["result"]["agent"]["name"], "worker"); + + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:return", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + let wait_socket = socket_path.clone(); + let wait_pane = pane_id.clone(); + let waiter = thread::spawn(move || { + run_cli( + &wait_socket, + &[ + "agent", + "wait", + &wait_pane, + "--until", + "idle", + "--timeout", + "2000", + ], + ) + }); + thread::sleep(Duration::from_millis(150)); + assert!( + run_cli(&socket_path, &["agent", "rename", "worker", "reviewer"]) + .status + .success() + ); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &pane_id, + "--source", + "custom:return", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + let waited = waiter.join().unwrap(); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited["result"]["agent"]["agent_status"], "idle"); + assert_eq!(waited["result"]["agent"]["name"], "reviewer"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_pins_the_original_terminal_when_name_is_reused() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let first = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let split = run_cli_json( + &socket_path, + &["pane", "split", &first, "--direction", "right"], + ); + let second = split["result"]["pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &first, + "--source", + "custom:race", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + assert!( + run_cli(&socket_path, &["agent", "rename", &first, "worker"]) + .status + .success() + ); + + let wait_socket = socket_path.clone(); + let waiter = thread::spawn(move || { + run_cli( + &wait_socket, + &["agent", "wait", "worker", "--timeout", "2000"], + ) + }); + thread::sleep(Duration::from_millis(250)); + assert!( + run_cli(&socket_path, &["agent", "rename", "worker", "--clear"]) + .status + .success() + ); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &second, + "--source", + "custom:race", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + assert!( + run_cli(&socket_path, &["agent", "rename", &second, "worker"]) + .status + .success() + ); + + let waited = waiter.join().unwrap(); + assert_eq!(waited.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); + assert_eq!(error["error"]["code"], "agent_not_running"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn agent_wait_ignores_other_panes_and_errors_when_its_pane_closes() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let first = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let split = run_cli_json( + &socket_path, + &["pane", "split", &first, "--direction", "right"], + ); + let second = split["result"]["pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &first, + "--source", + "custom:close", + "--agent", + "pi", + "--state", + "working", + ], + ) + .status + .success()); + assert!( + run_cli(&socket_path, &["agent", "rename", &first, "worker"]) + .status + .success() + ); + + let wait_socket = socket_path.clone(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + thread::spawn(move || { + let _ = done_tx.send(run_cli( + &wait_socket, + &["agent", "wait", "worker", "--timeout", "3000"], + )); + }); + thread::sleep(Duration::from_millis(150)); + assert!(run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &second, + "--source", + "custom:close", + "--agent", + "pi", + "--state", + "idle", + ], + ) + .status + .success()); + thread::sleep(Duration::from_millis(150)); + assert!(matches!( + done_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + assert!(run_cli(&socket_path, &["pane", "close", &first]) + .status + .success()); + let waited = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!(waited.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); + assert_eq!(error["error"]["code"], "agent_not_running"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/harness.rs b/tests/cli/harness.rs new file mode 100644 index 00000000..91dab149 --- /dev/null +++ b/tests/cli/harness.rs @@ -0,0 +1,608 @@ +pub(super) use std::fs; +pub(super) use std::io::{BufRead, BufReader, Write}; +pub(super) use std::os::unix::net::{UnixListener, UnixStream}; +pub(super) use std::path::{Path, PathBuf}; +pub(super) use std::process::{Command, Stdio}; +pub(super) use std::thread; +pub(super) use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +pub(super) use crate::support::{ + cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, + unregister_spawned_herdr_pid, CURRENT_PROTOCOL, +}; +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; + +pub(super) const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str = + "example.worktree-bootstrap-ef876653ffc3"; + +pub(super) fn unique_test_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + PathBuf::from(format!("/tmp/hcli-{}-{nanos}", std::process::id())) +} + +pub(super) fn managed_github_plugin_dir(config_home: &Path) -> PathBuf { + config_home.join("herdr-dev").join("plugins").join("github") +} + +pub(super) fn path_missing_or_empty(path: &Path) -> bool { + match fs::read_dir(path) { + Ok(mut entries) => entries.next().is_none(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, + Err(err) => panic!("failed to read {}: {err}", path.display()), + } +} + +pub(super) fn run_git(repo: &Path, args: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .unwrap(); + assert!( + status.success(), + "git command failed: git -C {} {}", + repo.display(), + args.join(" ") + ); +} + +pub(super) fn create_committed_repo(path: &Path) { + fs::create_dir_all(path).unwrap(); + run_git(path, &["init", "--quiet"]); + run_git(path, &["config", "user.email", "herdr@example.invalid"]); + run_git(path, &["config", "user.name", "Herdr Test"]); + fs::write(path.join("README.md"), "test\n").unwrap(); + run_git(path, &["add", "README.md"]); + run_git(path, &["commit", "--quiet", "-m", "initial"]); +} + +pub(super) struct SpawnedHerdr { + _master: Box<dyn MasterPty + Send>, + pub(super) child: Box<dyn Child + Send + Sync>, +} + +pub(super) struct SpawnedServerProcess { + child: std::process::Child, +} + +impl Drop for SpawnedServerProcess { + fn drop(&mut self) { + let pid = self.child.id(); + let _ = self.child.kill(); + let _ = self.child.wait(); + unregister_spawned_herdr_pid(Some(pid)); + } +} + +impl Drop for SpawnedHerdr { + fn drop(&mut self) { + let pid = self.child.process_id(); + let _ = self.child.kill(); + + if let Some(pid) = pid { + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + let mut status = 0; + let result = + unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; + if result == pid as libc::pid_t || result == -1 { + break; + } + thread::sleep(Duration::from_millis(20)); + } + + unregister_spawned_herdr_pid(Some(pid)); + } + } +} + +pub(super) fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) { + drop(spawned); + cleanup_test_base(&base); +} + +pub(super) fn wait_for_socket(path: &Path, timeout: Duration) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if path.exists() && std::os::unix::net::UnixStream::connect(path).is_ok() { + return; + } + thread::sleep(Duration::from_millis(25)); + } + panic!("socket did not appear at {}", path.display()); +} + +pub(super) fn spawn_herdr( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, +) -> SpawnedHerdr { + spawn_herdr_with_config( + config_home, + runtime_dir, + socket_path, + None, + "onboarding = false\n", + ) +} + +pub(super) fn spawn_herdr_with_pane_history( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, +) -> SpawnedHerdr { + spawn_herdr_with_config( + config_home, + runtime_dir, + socket_path, + None, + "onboarding = false\n[experimental]\npane_history = true\n", + ) +} + +pub(super) fn app_dir_name() -> &'static str { + if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + } +} + +pub(super) fn named_session_socket(config_home: &Path, session: &str) -> PathBuf { + config_home + .join(app_dir_name()) + .join("sessions") + .join(session) + .join("herdr.sock") +} + +pub(super) fn spawn_named_server( + config_home: &Path, + runtime_dir: &Path, + session: &str, +) -> SpawnedServerProcess { + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); + fs::create_dir_all(runtime_dir).unwrap(); + register_runtime_dir(runtime_dir); + fs::write( + config_home.join(app_dir_name()).join("config.toml"), + "onboarding = false\n", + ) + .unwrap(); + + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command + .args(["--session", session, "server"]) + .env("XDG_CONFIG_HOME", config_home) + .env("XDG_RUNTIME_DIR", runtime_dir) + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + let child = command.spawn().unwrap(); + register_spawned_herdr_pid(Some(child.id())); + SpawnedServerProcess { child } +} + +pub(super) fn run_named_cli( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], +) -> std::process::Output { + run_named_cli_with_socket_override(config_home, runtime_dir, args, None) +} + +pub(super) fn run_named_cli_with_socket_override( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], + socket_override: Option<&Path>, +) -> std::process::Output { + run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override) +} + +pub(super) fn run_named_cli_with_env( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], + envs: &[(&str, &Path)], +) -> std::process::Output { + run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None) +} + +pub(super) fn run_named_cli_with_env_and_socket_override( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], + envs: &[(&str, &Path)], + socket_override: Option<&Path>, +) -> std::process::Output { + 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_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV"); + for (key, value) in envs { + command.env(key, value); + } + if let Some(socket_override) = socket_override { + command.env("HERDR_SOCKET_PATH", socket_override); + } else { + command.env_remove("HERDR_SOCKET_PATH"); + } + command.output().unwrap() +} + +pub(super) fn run_named_cli_json( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], +) -> serde_json::Value { + let output = run_named_cli(config_home, runtime_dir, args); + assert!( + output.status.success(), + "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}", + args.join(" "), + output.status.code(), + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +pub(super) fn spawn_herdr_with_path( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, + path_override: Option<&Path>, +) -> SpawnedHerdr { + spawn_herdr_with_config( + config_home, + runtime_dir, + socket_path, + path_override, + "onboarding = false\n", + ) +} + +pub(super) fn spawn_herdr_with_config( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, + path_override: Option<&Path>, + config_toml: &str, +) -> SpawnedHerdr { + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); + fs::create_dir_all(runtime_dir).unwrap(); + register_runtime_dir(runtime_dir); + fs::write( + config_home.join(app_dir_name()).join("config.toml"), + config_toml, + ) + .unwrap(); + + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + cmd.arg("server"); + cmd.env("XDG_CONFIG_HOME", config_home); + cmd.env("XDG_RUNTIME_DIR", runtime_dir); + cmd.env("HERDR_SOCKET_PATH", socket_path); + cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); + cmd.env("SHELL", "/bin/sh"); + cmd.env_remove("HERDR_ENV"); + if let Some(path) = path_override { + cmd.env("PATH", path); + } + + let child = pair.slave.spawn_command(cmd).unwrap(); + register_spawned_herdr_pid(child.process_id()); + SpawnedHerdr { + _master: pair.master, + child, + } +} + +pub(super) fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command.args(args); + command.env("HERDR_SOCKET_PATH", socket_path); + command.output().unwrap() +} + +pub(super) fn run_cli_in_dir( + socket_path: &Path, + args: &[&str], + current_dir: &Path, +) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command.args(args); + command.current_dir(current_dir); + command.env("HERDR_SOCKET_PATH", socket_path); + command.output().unwrap() +} + +pub(super) fn pane_topology_snapshot(list_response: &serde_json::Value) -> Vec<serde_json::Value> { + list_response["result"]["panes"] + .as_array() + .unwrap() + .iter() + .map(|pane| { + serde_json::json!({ + "pane_id": pane["pane_id"], + "terminal_id": pane["terminal_id"], + "workspace_id": pane["workspace_id"], + "tab_id": pane["tab_id"], + "focused": pane["focused"], + }) + }) + .collect() +} + +pub(super) fn run_cli_json(socket_path: &Path, args: &[&str]) -> serde_json::Value { + let output = run_cli(socket_path, args); + parse_cli_json_output(args, output) +} + +pub(super) fn run_cli_json_in_dir( + socket_path: &Path, + args: &[&str], + current_dir: &Path, +) -> serde_json::Value { + let output = run_cli_in_dir(socket_path, args, current_dir); + parse_cli_json_output(args, output) +} + +pub(super) fn parse_cli_json_output( + args: &[&str], + output: std::process::Output, +) -> serde_json::Value { + assert!( + output.status.success(), + "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}", + args.join(" "), + output.status.code(), + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + + serde_json::from_slice(&output.stdout).unwrap_or_else(|err| { + panic!( + "failed to parse JSON response for `herdr {}`: {}\nstdout: {}\nstderr: {}", + args.join(" "), + err, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} + +pub(super) fn wait_until( + timeout: Duration, + interval: Duration, + mut condition: impl FnMut() -> bool, +) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if condition() { + return true; + } + thread::sleep(interval); + } + false +} + +pub(super) fn pane_read_recent_contains(socket_path: &Path, pane_id: &str, expected: &str) -> bool { + let output = run_cli( + socket_path, + &["pane", "read", pane_id, "--source", "recent"], + ); + if !output.status.success() { + return false; + } + String::from_utf8_lossy(&output.stdout).contains(expected) +} + +pub(super) fn process_exists(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + true + } else { + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + } +} + +pub(super) fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if !process_exists(pid) { + return true; + } + thread::sleep(Duration::from_millis(25)); + } + !process_exists(pid) +} + +pub(super) fn wait_for_pid_file(pid_file: &Path, timeout: Duration) -> Result<u32, String> { + const STABLE_PID_CONTENT_WINDOW: Duration = Duration::from_millis(250); + + let deadline = Instant::now() + timeout; + let mut last_contents = String::new(); + let mut stable_candidate: Option<(String, u32, Instant)> = None; + + while Instant::now() < deadline { + if let Ok(contents) = fs::read_to_string(pid_file) { + let trimmed = contents.trim().to_string(); + last_contents = contents; + + if let Ok(pid) = trimmed.parse::<u32>() { + match &stable_candidate { + Some((candidate_text, candidate_pid, stable_since)) + if candidate_text == &trimmed && *candidate_pid == pid => + { + if stable_since.elapsed() >= STABLE_PID_CONTENT_WINDOW { + return Ok(pid); + } + } + _ => { + stable_candidate = Some((trimmed, pid, Instant::now())); + } + } + } else { + stable_candidate = None; + } + } + + thread::sleep(Duration::from_millis(25)); + } + + Err(format!( + "pid file {} did not contain stable parseable pid before timeout; last contents={:?}", + pid_file.display(), + last_contents + )) +} + +#[test] +fn wait_for_pid_file_retries_until_pid_is_written() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let pid_file = base.join("delayed.pid"); + fs::write(&pid_file, "").unwrap(); + + let writer = thread::spawn({ + let pid_file = pid_file.clone(); + move || { + thread::sleep(Duration::from_millis(100)); + fs::write(pid_file, "424242\n").unwrap(); + } + }); + + let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap(); + assert_eq!(pid, 424242); + + writer.join().unwrap(); + cleanup_test_base(&base); +} + +#[test] +fn wait_for_pid_file_errors_when_file_never_contains_pid() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let pid_file = base.join("empty.pid"); + fs::write(&pid_file, "").unwrap(); + + let err = wait_for_pid_file(&pid_file, Duration::from_millis(150)).unwrap_err(); + assert!( + err.contains("did not contain stable parseable pid"), + "unexpected error: {err}" + ); + + cleanup_test_base(&base); +} + +#[test] +fn wait_for_pid_file_rejects_unparseable_partial_write_until_stable_contents() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let pid_file = base.join("partial-race.pid"); + fs::write(&pid_file, "").unwrap(); + + let writer = thread::spawn({ + let pid_file = pid_file.clone(); + move || { + thread::sleep(Duration::from_millis(40)); + fs::write(&pid_file, "pid=").unwrap(); + thread::sleep(Duration::from_millis(40)); + fs::write(&pid_file, "pid=424242").unwrap(); + thread::sleep(Duration::from_millis(40)); + fs::write(&pid_file, "424242\n").unwrap(); + } + }); + + let start = Instant::now(); + let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap(); + assert_eq!(pid, 424242); + assert!( + start.elapsed() >= Duration::from_millis(300), + "helper should wait for stable complete contents, elapsed={:?}", + start.elapsed() + ); + + writer.join().unwrap(); + cleanup_test_base(&base); +} + +pub(super) fn send_request(socket_path: &Path, json: &str) -> serde_json::Value { + let mut stream = UnixStream::connect(socket_path).unwrap(); + stream.write_all(json.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + + let mut line = String::new(); + let mut reader = BufReader::new(stream); + reader.read_line(&mut line).unwrap(); + serde_json::from_str(&line).unwrap() +} + +pub(super) fn write_fake_pong( + stream: &mut UnixStream, + request: &serde_json::Value, + version: &str, + protocol: u32, +) { + writeln!( + stream, + "{}", + serde_json::json!({ + "id": request["id"], + "result": { + "type": "pong", + "version": version, + "protocol": protocol, + "capabilities": { + "live_handoff": true, + "detached_server_daemon": true + } + } + }) + ) + .unwrap(); + stream.flush().unwrap(); +} + +pub(super) fn accept_fake_cli_operation(listener: &UnixListener) -> (UnixStream, String) { + loop { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + if request["method"] != "ping" { + return (stream, line); + } + + write_fake_pong( + &mut stream, + &request, + "different-build-same-protocol", + CURRENT_PROTOCOL, + ); + } +} diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs new file mode 100644 index 00000000..b2edd3e7 --- /dev/null +++ b/tests/cli/hooks.rs @@ -0,0 +1,308 @@ +use super::harness::*; + +fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook( + "src/integration/assets/claude/herdr-agent-state.sh", + &[action], + hook_input, + ) +} + +fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook( + "src/integration/assets/codex/herdr-agent-state.sh", + &[action], + hook_input, + ) +} + +fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook( + "src/integration/assets/copilot/herdr-agent-state.sh", + &[], + hook_input, + ) +} + +fn run_devin_hook( + action: &str, + hook_input: &str, + envs: &[(&str, &str)], +) -> Option<serde_json::Value> { + run_shell_hook_with_env( + "src/integration/assets/devin/herdr-agent-state.sh", + &[action], + hook_input, + envs, + ) +} + +fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> { + run_shell_hook_with_env(asset_path, args, hook_input, &[]) +} + +fn run_shell_hook_with_env( + asset_path: &str, + args: &[&str], + hook_input: &str, + envs: &[(&str, &str)], +) -> Option<serde_json::Value> { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_millis(700); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#); + let _ = stream.write_all(b"\n"); + let _ = stream.flush(); + return Some(line); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("accept failed: {err}"), + } + } + None + }); + + let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path); + let mut command = Command::new("bash"); + command + .arg(hook_path) + .args(args) + .env("HERDR_ENV", "1") + .env("HERDR_SOCKET_PATH", &socket_path) + .env("HERDR_PANE_ID", "p_test") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in envs { + command.env(key, value); + } + let mut child = command.spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(hook_input.as_bytes()).unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "hook failed: status={:?} stderr={} stdout={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + + let request = server.join().unwrap(); + cleanup_test_base(&base); + request.map(|line| serde_json::from_str(&line).unwrap()) +} + +#[test] +fn claude_hook_ignores_state_actions() { + let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#; + + assert!(run_claude_hook("working", subagent_input).is_none()); + assert!(run_claude_hook("blocked", subagent_input).is_none()); +} + +#[test] +fn claude_hook_ignores_subagent_completion_reports() { + let subagent_input = + r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#; + + assert!(run_claude_hook("working", subagent_input).is_none()); + assert!(run_claude_hook("idle", subagent_input).is_none()); + assert!(run_claude_hook("release", subagent_input).is_none()); +} + +#[test] +fn claude_hook_keeps_parent_agent_type_only_blocked() { + let request = run_claude_hook( + "blocked", + r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#, + ); + + assert!(request.is_none()); +} + +#[test] +fn claude_hook_reports_session_id_from_stdin() { + let request = run_claude_hook( + "session", + r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#, + ) + .expect("session start should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent_session_id"], "claude-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn codex_hook_reports_session_id_from_stdin() { + let request = run_codex_hook( + "session", + r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#, + ) + .expect("codex hook should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent_session_id"], "codex-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn copilot_hook_reports_session_id_from_stdin() { + let request = run_copilot_hook( + r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#, + ) + .expect("copilot session start should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent"], "copilot"); + assert_eq!(request["params"]["agent_session_id"], "copilot-session"); + assert!(request["params"].get("state").is_none()); + + let camel = run_copilot_hook( + r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#, + ) + .expect("copilot camelCase session start should report session identity"); + + assert_eq!(camel["method"], "pane.report_agent_session"); + assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session"); + assert!(camel["params"].get("state").is_none()); +} + +#[test] +fn copilot_hook_does_not_report_lifecycle_state() { + for payload in [ + r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#, + r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#, + r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#, + r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#, + r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#, + ] { + assert!( + run_copilot_hook(payload).is_none(), + "copilot session-only hook should ignore lifecycle payload {payload}" + ); + } +} + +#[test] +fn devin_hook_ignores_prompt_session_list_fallback() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, + ), + ], + ); + + assert!(request.is_none()); +} + +#[test] +fn devin_hook_reports_session_id_from_stdin_without_state() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#, + &[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)], + ) + .expect("devin session start should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent"], "devin"); + assert_eq!(request["params"]["agent_session_id"], "devin-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn devin_hook_prefers_hook_session_id_over_list() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#, + ), + ], + ) + .expect("devin tool hook should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent_session_id"], "fresh-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn devin_hook_reports_tool_session_from_list_without_state() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, + ), + ], + ) + .expect("devin tool hook should report session identity"); + + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent"], "devin"); + assert_eq!(request["params"]["agent_session_id"], "devin-session"); + assert!(request["params"].get("state").is_none()); +} + +#[test] +fn devin_hook_ignores_startup_session_list_fallback() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"SessionStart","source":"startup"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#, + ), + ], + ); + + assert!(request.is_none()); +} + +#[test] +fn devin_hook_ignores_non_matching_session_list_entries() { + let request = run_devin_hook( + "session", + r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, + &[ + ("DEVIN_PROJECT_DIR", "/tmp/project"), + ( + "HERDR_DEVIN_LIST_JSON", + r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#, + ), + ], + ); + + assert!(request.is_none()); +} diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs new file mode 100644 index 00000000..0fdff6f1 --- /dev/null +++ b/tests/cli/mod.rs @@ -0,0 +1,13 @@ +mod agent_options; +mod agent_transport; +mod agent_wait; +mod agents; +mod harness; +mod hooks; +mod panes; +mod plugins; +mod protocol; +mod protocol_guard; +mod sessions; +mod surface; +mod workspace; diff --git a/tests/cli/panes.rs b/tests/cli/panes.rs new file mode 100644 index 00000000..32110b36 --- /dev/null +++ b/tests/cli/panes.rs @@ -0,0 +1,568 @@ +use super::harness::*; + +#[test] +fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + let workspace_id = created_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + + let created_tab = run_cli( + &socket_path, + &["tab", "create", "--workspace", &workspace_id], + ); + assert!(created_tab.status.success()); + let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap(); + let second_root_pane_id = created_tab_json["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let closed = run_cli(&socket_path, &["pane", "close", &second_root_pane_id]); + assert!(closed.status.success()); + let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); + assert_eq!(closed_json["result"]["type"], "ok"); + + let workspaces = run_cli(&socket_path, &["workspace", "list"]); + assert!(workspaces.status.success()); + let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); + assert_eq!( + workspaces_json["result"]["workspaces"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + workspaces_json["result"]["workspaces"][0]["workspace_id"], + workspace_id + ); + + let tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]); + assert!(tabs.status.success()); + let tabs_json: serde_json::Value = serde_json::from_slice(&tabs.stdout).unwrap(); + assert_eq!(tabs_json["result"]["tabs"].as_array().unwrap().len(), 1); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn pane_close_removes_the_workspace_when_it_closes_the_last_pane() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + let root_pane_id = created_json["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let closed = run_cli(&socket_path, &["pane", "close", &root_pane_id]); + assert!(closed.status.success()); + let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); + assert_eq!(closed_json["result"]["type"], "ok"); + + let workspaces = run_cli(&socket_path, &["workspace", "list"]); + assert!(workspaces.status.success()); + let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); + assert!(workspaces_json["result"]["workspaces"] + .as_array() + .unwrap() + .is_empty()); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn pane_run_read_and_wait_commands_work() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let create = run_cli( + &socket_path, + &[ + "pane", + "run", + "1-1", + "echo alpha && echo beta && printf 'ready\\n'", + ], + ); + assert!(create.status.success()); + + let started = Instant::now(); + let waited = run_cli( + &socket_path, + &[ + "pane", + "wait-output", + "1-1", + "--match", + "ready", + "--source", + "recent", + "--lines", + "40", + "--timeout", + "5000", + ], + ); + let elapsed = started.elapsed(); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + assert!( + elapsed < Duration::from_millis(500), + "already-matching wait took {elapsed:?}" + ); + let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited_json["result"]["type"], "output_matched"); + + let read = run_cli( + &socket_path, + &["pane", "read", "1-1", "--source", "recent", "--lines", "40"], + ); + assert!(read.status.success()); + let text = String::from_utf8(read.stdout).unwrap(); + assert!(text.contains("alpha")); + assert!(text.contains("ready")); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn wait_output_matches_recent_unwrapped_text() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + + let token = "WRAP_WAIT_TEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; + let script = base.join("emit-long-token.sh"); + std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{token}'\n")).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + } + + let run = run_cli( + &socket_path, + &["pane", "run", "1-1", &format!("sh {}", script.display())], + ); + assert!(run.status.success()); + + let waited = run_cli( + &socket_path, + &[ + "pane", + "wait-output", + "1-1", + "--match", + token, + "--source", + "recent", + "--lines", + "80", + "--timeout", + "5000", + ], + ); + assert!( + waited.status.success(), + "stderr: {} stdout: {}", + String::from_utf8_lossy(&waited.stderr), + String::from_utf8_lossy(&waited.stdout) + ); + + let read = run_cli( + &socket_path, + &[ + "pane", + "read", + "1-1", + "--source", + "recent-unwrapped", + "--lines", + "80", + ], + ); + assert!(read.status.success()); + let text = String::from_utf8(read.stdout).unwrap(); + assert!(text.contains(token)); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn closing_pane_terminates_processes_inside_it() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + + let split = run_cli( + &socket_path, + &["pane", "split", "1-1", "--direction", "right"], + ); + assert!(split.status.success()); + let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap(); + let pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap(); + + let pid_file = base.join("pane-close.pid"); + let command = format!( + "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", + pid_file.display() + ); + let ran = run_cli(&socket_path, &["pane", "run", pane_id, &command]); + assert!( + ran.status.success(), + "stderr: {}", + String::from_utf8_lossy(&ran.stderr) + ); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && !pid_file.exists() { + thread::sleep(Duration::from_millis(25)); + } + assert!(pid_file.exists(), "pid file was not created"); + + let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| { + panic!("failed to read pane child pid: {err}"); + }); + assert!(process_exists(pid), "child process was not running"); + + let closed = run_cli(&socket_path, &["pane", "close", pane_id]); + assert!( + closed.status.success(), + "stderr: {}", + String::from_utf8_lossy(&closed.stderr) + ); + assert!( + wait_for_pid_exit(pid, Duration::from_secs(3)), + "process {pid} survived pane close" + ); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn closing_workspace_terminates_processes_inside_it() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + + let pid_file = base.join("workspace-close.pid"); + let command = format!( + "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", + pid_file.display() + ); + let ran = run_cli(&socket_path, &["pane", "run", "1-1", &command]); + assert!( + ran.status.success(), + "stderr: {}", + String::from_utf8_lossy(&ran.stderr) + ); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && !pid_file.exists() { + thread::sleep(Duration::from_millis(25)); + } + assert!(pid_file.exists(), "pid file was not created"); + + let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| { + panic!("failed to read pane child pid: {err}"); + }); + assert!(process_exists(pid), "child process was not running"); + + let closed = run_cli(&socket_path, &["workspace", "close", "1"]); + assert!( + closed.status.success(), + "stderr: {}", + String::from_utf8_lossy(&closed.stderr) + ); + assert!( + wait_for_pid_exit(pid, Duration::from_secs(3)), + "process {pid} survived workspace close" + ); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn workspace_ids_and_public_pane_ids_are_stable() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let ws1_json = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + let ws1_id = ws1_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + + let split_12_json = run_cli_json( + &socket_path, + &["pane", "split", "1-1", "--direction", "right", "--no-focus"], + ); + assert_eq!( + split_12_json["result"]["pane"]["pane_id"], + format!("{ws1_id}:p2") + ); + + let split_13_json = run_cli_json( + &socket_path, + &["pane", "split", "1-1", "--direction", "down", "--no-focus"], + ); + assert_eq!( + split_13_json["result"]["pane"]["pane_id"], + format!("{ws1_id}:p3") + ); + + let ws2_json = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", "/tmp", "--no-focus"], + ); + let ws2_id = ws2_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + assert_ne!(ws2_id, ws1_id); + + let ws2_focus = run_cli(&socket_path, &["workspace", "focus", &ws2_id]); + assert!( + ws2_focus.status.success(), + "stderr: {}", + String::from_utf8_lossy(&ws2_focus.stderr) + ); + + let ws2_split_json = run_cli_json( + &socket_path, + &["pane", "split", "2-1", "--direction", "right", "--no-focus"], + ); + assert_eq!( + ws2_split_json["result"]["pane"]["pane_id"], + format!("{ws2_id}:p2") + ); + + let ws3_json = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", "/", "--no-focus"], + ); + let ws3_id = ws3_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + assert_ne!(ws3_id, ws1_id); + assert_ne!(ws3_id, ws2_id); + + let close_ws2 = run_cli(&socket_path, &["workspace", "close", &ws2_id]); + assert!( + close_ws2.status.success(), + "stderr: {}", + String::from_utf8_lossy(&close_ws2.stderr) + ); + + let workspaces_json = run_cli_json(&socket_path, &["workspace", "list"]); + let ids: Vec<String> = workspaces_json["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|ws| ws["workspace_id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, vec![ws1_id.clone(), ws3_id.clone()]); + + let new_ws_json = run_cli_json( + &socket_path, + &["workspace", "create", "--cwd", "/var/tmp", "--no-focus"], + ); + let new_ws_id = new_ws_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + assert_ne!(new_ws_id, ws1_id); + assert_ne!(new_ws_id, ws2_id); + assert_ne!(new_ws_id, ws3_id); + + let ws3_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws3_id]); + assert_eq!( + ws3_panes_json["result"]["panes"][0]["pane_id"], + format!("{ws3_id}:p1") + ); + + let close_middle = run_cli(&socket_path, &["pane", "close", &format!("{ws1_id}-2")]); + assert!( + close_middle.status.success(), + "stderr: {}", + String::from_utf8_lossy(&close_middle.stderr) + ); + + let ws1_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws1_id]); + let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"] + .as_array() + .unwrap() + .iter() + .map(|pane| pane["pane_id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + pane_ids, + vec![format!("{ws1_id}:p1"), format!("{ws1_id}:p3")] + ); + + let closed_lookup = run_cli(&socket_path, &["pane", "get", &format!("{ws1_id}:p2")]); + assert!( + !closed_lookup.status.success(), + "closed pane id should not retarget: {}", + String::from_utf8_lossy(&closed_lookup.stdout) + ); + + let split_14_json = run_cli_json( + &socket_path, + &[ + "pane", + "split", + &format!("{ws1_id}:p1"), + "--direction", + "right", + "--no-focus", + ], + ); + assert_eq!( + split_14_json["result"]["pane"]["pane_id"], + format!("{ws1_id}:p4") + ); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn pane_shell_gets_herdr_socket_and_pane_env() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_env_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let env_capture = base.join("pane-env.txt"); + let ran = run_cli( + &socket_path, + &[ + "pane", + "run", + "1-1", + &format!( + "printf '%s\\n%s\\n' \"$HERDR_SOCKET_PATH\" \"$HERDR_PANE_ID\" > {}", + env_capture.display() + ), + ], + ); + assert!(ran.status.success()); + + let deadline = Instant::now() + Duration::from_secs(3); + let mut text = String::new(); + while Instant::now() < deadline { + if env_capture.exists() { + text = fs::read_to_string(&env_capture).unwrap(); + if text.contains(&socket_path.display().to_string()) && text.contains(&pane_id) { + break; + } + } + thread::sleep(Duration::from_millis(25)); + } + assert!(env_capture.exists(), "env capture file was not created"); + assert!( + text.contains(&socket_path.display().to_string()), + "env file was: {text:?}" + ); + assert!(text.contains(&pane_id), "env file was: {text:?}"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs new file mode 100644 index 00000000..ab1dd21a --- /dev/null +++ b/tests/cli/plugins.rs @@ -0,0 +1,932 @@ +use super::harness::*; + +#[test] +fn plugin_link_list_unlink_cli_smoke_test() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let plugin_dir = base.join("plugins").join("layout"); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.layout" +name = "Layout" +version = "0.1.0" +min_herdr_version = "0.6.10" +description = "Apply a preferred Herdr layout" + +[[actions]] +id = "apply" +title = "Apply layout" +contexts = ["workspace"] +command = ["sh", "-c", "echo layout"] + +[[events]] +on = "worktree.created" +command = ["sh", "-c", "echo worktree"] + +[[panes]] +id = "board" +title = "Board" +placement = "tab" +command = ["sh", "-c", "sleep 5"] +"#, + ) + .unwrap(); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + let workspace = run_cli_json( + &socket_path, + &[ + "workspace", + "create", + "--cwd", + base.to_str().unwrap(), + "--focus", + ], + ); + assert_eq!(workspace["result"]["type"], "workspace_created"); + + let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base); + assert_eq!(linked["result"]["type"], "plugin_linked"); + assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout"); + assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply"); + assert_eq!( + linked["result"]["plugin"]["events"][0]["on"], + "worktree.created" + ); + assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board"); + + let listed_human = run_cli(&socket_path, &["plugin", "list"]); + assert!(listed_human.status.success()); + assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout")); + + let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); + assert_eq!(listed["result"]["type"], "plugin_list"); + assert_eq!( + listed["result"]["plugins"][0]["plugin_id"], + "example.layout" + ); + + let invoked = run_cli_json( + &socket_path, + &[ + "plugin", + "action", + "invoke", + "apply", + "--plugin", + "example.layout", + ], + ); + assert_eq!(invoked["result"]["type"], "plugin_action_invoked"); + assert_eq!(invoked["result"]["action"]["action_id"], "apply"); + + let logs = run_cli_json( + &socket_path, + &[ + "plugin", + "log", + "list", + "--plugin", + "example.layout", + "--limit", + "5", + ], + ); + assert_eq!(logs["result"]["type"], "plugin_log_list"); + assert!(!logs["result"]["logs"].as_array().unwrap().is_empty()); + + let pane = run_cli_json( + &socket_path, + &[ + "plugin", + "pane", + "open", + "--plugin", + "example.layout", + "--entrypoint", + "board", + "--env", + "HERDR_ROLE=board", + "--no-focus", + ], + ); + assert_eq!(pane["result"]["type"], "plugin_pane_opened"); + assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board"); + + let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]); + assert_eq!(missing_plugin_value.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&missing_plugin_value.stderr) + .contains("missing value for --plugin")); + + let invalid_limit = run_cli( + &socket_path, + &["plugin", "log", "list", "--limit", "not-a-number"], + ); + assert_eq!(invalid_limit.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value")); + + let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]); + assert_eq!(unlinked["result"]["type"], "plugin_unlinked"); + assert_eq!(unlinked["result"]["removed"], true); + + let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn plugin_install_list_uninstall_offline_cli_smoke_test() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo bootstrap"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let install = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "plugins", + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--yes", + ], + &[ + ("GIT_CONFIG_GLOBAL", &git_config), + ("HERDR_SESSION", Path::new("leaked-session")), + ], + ); + assert!( + install.status.success(), + "install failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "plugins", "plugin", "list", "--json"], + ); + let plugin = &listed["result"]["plugins"][0]; + assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap"); + assert_eq!(plugin["source"]["kind"], "github"); + assert_eq!(plugin["source"]["owner"], "ogulcancelik"); + assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples"); + assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap"); + 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"); + assert!( + managed_path + .join("worktree-bootstrap") + .join("built.txt") + .exists(), + "build artifact should be preserved in managed checkout" + ); + assert!( + !managed_path + .join("worktree-bootstrap") + .join("leaked-session.txt") + .exists(), + "build command should not inherit HERDR_SESSION" + ); + + let uninstall = run_named_cli( + &config_home, + &runtime_dir, + &[ + "--session", + "plugins", + "plugin", + "uninstall", + "example.worktree-bootstrap", + ], + ); + assert!( + uninstall.status.success(), + "uninstall failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&uninstall.stdout), + String::from_utf8_lossy(&uninstall.stderr) + ); + assert!( + !managed_path.exists(), + "managed checkout should be deleted on uninstall" + ); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "plugins", "plugin", "list", "--json"], + ); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_build_failure_does_not_register_or_create_checkout() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("build-fail"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.build-fail" +name = "Build Fail" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"] + +[[actions]] +id = "run" +title = "Run" +command = ["sh", "-c", "echo should-not-install"] +"#, + ) + .unwrap(); + run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]); + run_git( + &source_repo, + &["commit", "--quiet", "-m", "add failing plugin"], + ); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let install = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "plugins", + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/build-fail", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + !install.status.success(), + "install should fail when build command fails" + ); + assert_eq!(install.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&install.stderr); + assert!(stderr.contains("error: plugin build failed"), "{stderr}"); + assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}"); + assert!(stderr.contains(" build: 1/1"), "{stderr}"); + assert!(stderr.contains(" cwd: "), "{stderr}"); + assert!( + stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"), + "{stderr}" + ); + assert!(stderr.contains(" status: exit status: 7"), "{stderr}"); + assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}"); + assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}"); + assert!(stderr.contains("Plugin was not installed."), "{stderr}"); + assert!(!stderr.contains("Error: Custom"), "{stderr}"); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "plugins", "plugin", "list", "--json"], + ); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + + assert!( + path_missing_or_empty(&managed_github_plugin_dir(&config_home)), + "failed build should not leave managed checkouts" + ); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_build_spawn_failure_prints_clean_error() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("missing-tool"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.missing-tool" +name = "Missing Tool" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["definitely-missing-herdr-build-tool-xyz"] + +[[actions]] +id = "run" +title = "Run" +command = ["sh", "-c", "echo should-not-install"] +"#, + ) + .unwrap(); + run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]); + run_git( + &source_repo, + &["commit", "--quiet", "-m", "add missing tool plugin"], + ); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let install = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "plugins", + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/missing-tool", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + !install.status.success(), + "install should fail when build command cannot start" + ); + assert_eq!(install.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&install.stderr); + assert!(stderr.contains("error: plugin build failed"), "{stderr}"); + assert!( + stderr.contains(" plugin: example.missing-tool"), + "{stderr}" + ); + assert!(stderr.contains(" build: 1/1"), "{stderr}"); + assert!( + stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"), + "{stderr}" + ); + assert!(stderr.contains(" error: failed to start:"), "{stderr}"); + assert!(stderr.contains("Plugin was not installed."), "{stderr}"); + assert!(!stderr.contains("Error: Custom"), "{stderr}"); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "plugins", "plugin", "list", "--json"], + ); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_rejects_manifest_changed_by_build() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("manifest-mutator"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.manifest-mutator" +name = "Manifest Mutator" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["sh", "mutate.sh"] + +[[actions]] +id = "run" +title = "Run reviewed command" +command = ["sh", "-c", "echo reviewed"] +"#, + ) + .unwrap(); + fs::write( + plugin_dir.join("mutate.sh"), + r#"cat > herdr-plugin.toml <<'EOF' +id = "example.manifest-mutator" +name = "Manifest Mutator" +version = "0.1.0" +min_herdr_version = "0.0.1" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = ["sh", "mutate.sh"] + +[[actions]] +id = "run" +title = "Run reviewed command" +command = ["sh", "-c", "echo reviewed"] +EOF +"#, + ) + .unwrap(); + run_git(&source_repo, &["add", "manifest-mutator"]); + run_git( + &source_repo, + &["commit", "--quiet", "-m", "add mutating plugin"], + ); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let install = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "plugins", + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/manifest-mutator", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + !install.status.success(), + "install should fail when build changes reviewed manifest" + ); + let stderr = String::from_utf8_lossy(&install.stderr); + assert!( + stderr.contains("plugin build changed herdr-plugin.toml after install preview"), + "{stderr}" + ); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "plugins", "plugin", "list", "--json"], + ); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + + assert!( + path_missing_or_empty(&managed_github_plugin_dir(&config_home)), + "manifest mutation should not leave managed checkouts" + ); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_restores_previous_checkout_when_registration_fails() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("fake-herdr.sock"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.2.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo new"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let managed_checkout = config_home + .join("herdr-dev") + .join("plugins") + .join("github") + .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); + fs::create_dir_all(&managed_checkout).unwrap(); + fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap(); + + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let listener = UnixListener::bind(&socket_path).unwrap(); + let managed_checkout_for_server = managed_checkout.clone(); + let server = thread::spawn(move || { + let (mut first, first_line) = accept_fake_cli_operation(&listener); + let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first_request["method"], "plugin.list"); + writeln!( + first, + "{}", + serde_json::json!({ + "id": "cli:plugin", + "result": { + "type": "plugin_list", + "plugins": [{ + "plugin_id": "example.worktree-bootstrap", + "name": "Worktree Bootstrap", + "version": "0.1.0", + "min_herdr_version": "0.6.10", + "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), + "plugin_root": managed_checkout_for_server.display().to_string(), + "enabled": true, + "source": { + "kind": "github", + "owner": "ogulcancelik", + "repo": "herdr-plugin-examples", + "subdir": "worktree-bootstrap", + "resolved_commit": "old", + "managed_path": managed_checkout_for_server.display().to_string(), + "installed_unix_ms": 1 + } + }] + } + }) + ) + .unwrap(); + first.flush().unwrap(); + + let (mut second, second_line) = accept_fake_cli_operation(&listener); + let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); + assert_eq!(second_request["method"], "plugin.link"); + second + .write_all( + br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#, + ) + .unwrap(); + second.write_all(b"\n").unwrap(); + second.flush().unwrap(); + }); + + let install = run_named_cli_with_env_and_socket_override( + &config_home, + &runtime_dir, + &[ + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + Some(&socket_path), + ); + assert!( + !install.status.success(), + "install should fail when plugin.link fails" + ); + server.join().unwrap(); + assert!( + managed_checkout.join("old-marker").exists(), + "old checkout should be restored after registration failure" + ); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_rejects_server_that_drops_source_metadata() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("fake-herdr.sock"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo install"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let managed_checkout = config_home + .join("herdr-dev") + .join("plugins") + .join("github") + .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let listener = UnixListener::bind(&socket_path).unwrap(); + let managed_checkout_for_server = managed_checkout.clone(); + let server = thread::spawn(move || { + let (mut first, first_line) = accept_fake_cli_operation(&listener); + let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first_request["method"], "plugin.list"); + first + .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) + .unwrap(); + first.write_all(b"\n").unwrap(); + first.flush().unwrap(); + + let (mut second, second_line) = accept_fake_cli_operation(&listener); + let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); + assert_eq!(second_request["method"], "plugin.link"); + writeln!( + second, + "{}", + serde_json::json!({ + "id": "cli:plugin", + "result": { + "type": "plugin_linked", + "plugin": { + "plugin_id": "example.worktree-bootstrap", + "name": "Worktree Bootstrap", + "version": "0.1.0", + "min_herdr_version": "0.6.10", + "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), + "plugin_root": managed_checkout_for_server.display().to_string(), + "enabled": true, + "source": {"kind": "local"} + } + } + }) + ) + .unwrap(); + second.flush().unwrap(); + + let (mut third, third_line) = accept_fake_cli_operation(&listener); + let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); + assert_eq!(third_request["method"], "plugin.unlink"); + assert_eq!( + third_request["params"]["plugin_id"], + "example.worktree-bootstrap" + ); + third + .write_all( + br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#, + ) + .unwrap(); + third.write_all(b"\n").unwrap(); + third.flush().unwrap(); + }); + + let install = run_named_cli_with_env_and_socket_override( + &config_home, + &runtime_dir, + &[ + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + Some(&socket_path), + ); + assert!( + !install.status.success(), + "install should fail when server drops GitHub source metadata" + ); + server.join().unwrap(); + assert!( + !managed_checkout.exists(), + "new checkout should be removed after incompatible plugin.link response" + ); + + cleanup_test_base(&base); +} + +#[test] +fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("fake-herdr.sock"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("worktree-bootstrap"); + fs::create_dir_all(&plugin_dir).unwrap(); + create_committed_repo(&source_repo); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + r#" +id = "example.worktree-bootstrap" +name = "Worktree Bootstrap" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos", "windows"] + +[[actions]] +id = "bootstrap" +title = "Bootstrap" +command = ["sh", "-c", "echo install"] +"#, + ) + .unwrap(); + run_git( + &source_repo, + &["add", "worktree-bootstrap/herdr-plugin.toml"], + ); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + fs::create_dir_all(&config_home).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + let managed_checkout = config_home + .join("herdr-dev") + .join("plugins") + .join("github") + .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", + source_repo.display() + ), + ) + .unwrap(); + + let listener = UnixListener::bind(&socket_path).unwrap(); + let managed_checkout_for_server = managed_checkout.clone(); + let server = thread::spawn(move || { + let (mut first, _first_line) = accept_fake_cli_operation(&listener); + first + .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) + .unwrap(); + first.write_all(b"\n").unwrap(); + first.flush().unwrap(); + + let (mut second, _second_line) = accept_fake_cli_operation(&listener); + writeln!( + second, + "{}", + serde_json::json!({ + "id": "cli:plugin", + "result": { + "type": "plugin_linked", + "plugin": { + "plugin_id": "example.worktree-bootstrap", + "name": "Worktree Bootstrap", + "version": "0.1.0", + "min_herdr_version": "0.6.10", + "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), + "plugin_root": managed_checkout_for_server.display().to_string(), + "enabled": true, + "source": {"kind": "local"} + } + } + }) + ) + .unwrap(); + second.flush().unwrap(); + + let (mut third, third_line) = accept_fake_cli_operation(&listener); + let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); + assert_eq!(third_request["method"], "plugin.unlink"); + third + .write_all( + br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#, + ) + .unwrap(); + third.write_all(b"\n").unwrap(); + third.flush().unwrap(); + }); + + let install = run_named_cli_with_env_and_socket_override( + &config_home, + &runtime_dir, + &[ + "plugin", + "install", + "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + Some(&socket_path), + ); + assert!( + !install.status.success(), + "install should fail when source metadata is dropped and cleanup fails" + ); + server.join().unwrap(); + assert!( + managed_checkout.exists(), + "checkout should stay when server cleanup fails" + ); + + cleanup_test_base(&base); +} diff --git a/tests/cli/protocol.rs b/tests/cli/protocol.rs new file mode 100644 index 00000000..0c5571b8 --- /dev/null +++ b/tests/cli/protocol.rs @@ -0,0 +1,122 @@ +use super::harness::*; + +#[test] +fn cli_allows_same_protocol_different_version_and_preserves_server_error() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut ping_stream, _) = listener.accept().unwrap(); + let mut ping_line = String::new(); + BufReader::new(ping_stream.try_clone().unwrap()) + .read_line(&mut ping_line) + .unwrap(); + let ping: serde_json::Value = serde_json::from_str(&ping_line).unwrap(); + assert_eq!(ping["method"], "ping"); + write_fake_pong( + &mut ping_stream, + &ping, + "0.7.1-compatible-build", + CURRENT_PROTOCOL, + ); + + let (mut operation_stream, _) = listener.accept().unwrap(); + let mut operation_line = String::new(); + BufReader::new(operation_stream.try_clone().unwrap()) + .read_line(&mut operation_line) + .unwrap(); + let operation: serde_json::Value = serde_json::from_str(&operation_line).unwrap(); + assert_eq!(operation["method"], "agent.wait"); + operation_stream + .write_all( + br#"{"id":"cli:agent:wait","error":{"code":"not_implemented","message":"compatible server error"}}"#, + ) + .unwrap(); + operation_stream.write_all(b"\n").unwrap(); + operation_stream.flush().unwrap(); + }); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + "1-1", + "--until", + "idle", + "--timeout", + "5000", + ], + ); + + assert_eq!(waited.status.code(), Some(1)); + assert!(waited.stdout.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); + assert_eq!(error["id"], "cli:agent:wait"); + assert_eq!(error["error"]["code"], "not_implemented"); + assert_eq!(error["error"]["message"], "compatible server error"); + server.join().unwrap(); + cleanup_test_base(&base); +} + +#[test] +fn server_live_handoff_bypasses_protocol_guard() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "server.live_handoff"); + stream + .write_all(br#"{"id":"cli:server:live-handoff","result":{"type":"ok"}}"#) + .unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + }); + + let handoff = run_cli(&socket_path, &["server", "live-handoff"]); + assert!( + handoff.status.success(), + "stderr: {}", + String::from_utf8_lossy(&handoff.stderr) + ); + server.join().unwrap(); + cleanup_test_base(&base); +} + +#[test] +fn plugin_list_preserves_protocol_mismatch_envelope() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "ping"); + write_fake_pong(&mut stream, &request, "0.7.1", 14); + }); + + let listed = run_cli(&socket_path, &["plugin", "list", "--json"]); + assert_eq!(listed.status.code(), Some(1)); + assert!(listed.stdout.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&listed.stderr).unwrap(); + assert_eq!(error["id"], "cli:plugin"); + assert_eq!(error["error"]["code"], "protocol_mismatch"); + server.join().unwrap(); + cleanup_test_base(&base); +} diff --git a/tests/cli/protocol_guard.rs b/tests/cli/protocol_guard.rs new file mode 100644 index 00000000..4272ed28 --- /dev/null +++ b/tests/cli/protocol_guard.rs @@ -0,0 +1,87 @@ +use super::harness::*; + +#[test] +fn cli_rejects_protocol_mismatch_before_agent_wait_request() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut first_stream, _) = listener.accept().unwrap(); + let mut first_line = String::new(); + let mut first_reader = BufReader::new(first_stream.try_clone().unwrap()); + first_reader.read_line(&mut first_line).unwrap(); + let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); + if first_request["method"] == "ping" { + write_fake_pong(&mut first_stream, &first_request, "0.7.1", 14); + } else { + first_stream + .write_all( + br#"{"id":"cli:agent:wait","error":{"code":"not_implemented","message":"method not implemented yet"}}"#, + ) + .unwrap(); + } + if first_request["method"] != "ping" { + first_stream.write_all(b"\n").unwrap(); + first_stream.flush().unwrap(); + } + + let mut second_line = None; + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_millis(250); + while Instant::now() < deadline { + match listener.accept() { + Ok((second_stream, _)) => { + let mut line = String::new(); + let mut reader = BufReader::new(second_stream); + reader.read_line(&mut line).unwrap(); + second_line = Some(line); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("second accept failed: {err}"), + } + } + + (first_line, second_line) + }); + + let waited = run_cli( + &socket_path, + &[ + "agent", + "wait", + "1-1", + "--until", + "idle", + "--timeout", + "5000", + ], + ); + + assert_eq!(waited.status.code(), Some(1)); + assert!(waited.stdout.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); + assert_eq!(error["id"], "cli:agent:wait"); + assert_eq!(error["error"]["code"], "protocol_mismatch"); + let message = error["error"]["message"].as_str().unwrap(); + assert!( + message.contains(&format!("client protocol {CURRENT_PROTOCOL}")), + "message: {message}" + ); + assert!(message.contains("server protocol 14"), "message: {message}"); + assert!(message.contains("restart"), "message: {message}"); + + let (first_line, second_line) = server.join().unwrap(); + let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first_request["method"], "ping"); + assert!( + second_line.is_none(), + "mismatched CLI sent an operational request: {second_line:?}" + ); + + cleanup_test_base(&base); +} diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs new file mode 100644 index 00000000..4a4430e1 --- /dev/null +++ b/tests/cli/sessions.rs @@ -0,0 +1,671 @@ +use super::harness::*; + +#[test] +fn named_sessions_use_separate_servers_and_workspace_state() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + + let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); + let beta = spawn_named_server(&config_home, &runtime_dir, "beta"); + + wait_for_socket( + &named_session_socket(&config_home, "alpha"), + Duration::from_secs(5), + ); + wait_for_socket( + &named_session_socket(&config_home, "beta"), + Duration::from_secs(5), + ); + + run_named_cli_json( + &config_home, + &runtime_dir, + &[ + "--session", + "alpha", + "workspace", + "create", + "--label", + "alpha-ws", + "--no-focus", + ], + ); + run_named_cli_json( + &config_home, + &runtime_dir, + &[ + "--session", + "beta", + "workspace", + "create", + "--label", + "beta-ws", + "--no-focus", + ], + ); + + let alpha_list = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "alpha", "workspace", "list"], + ); + let beta_list = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "beta", "workspace", "list"], + ); + + let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|workspace| workspace["label"].as_str().unwrap()) + .collect(); + let beta_labels: Vec<_> = beta_list["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|workspace| workspace["label"].as_str().unwrap()) + .collect(); + + assert_eq!(alpha_labels, vec!["alpha-ws"]); + assert_eq!(beta_labels, vec!["beta-ws"]); + + let beta_via_explicit_session = run_named_cli_with_socket_override( + &config_home, + &runtime_dir, + &["--session", "beta", "workspace", "list"], + Some(&named_session_socket(&config_home, "alpha")), + ); + assert!( + beta_via_explicit_session.status.success(), + "stderr: {}", + String::from_utf8_lossy(&beta_via_explicit_session.stderr) + ); + let beta_via_explicit_session: serde_json::Value = + serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap(); + let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|workspace| workspace["label"].as_str().unwrap()) + .collect(); + assert_eq!(labels_via_explicit, vec!["beta-ws"]); + + let human_sessions = run_named_cli(&config_home, &runtime_dir, &["session", "list"]); + assert!(human_sessions.status.success()); + let human_sessions = String::from_utf8_lossy(&human_sessions.stdout); + assert!(human_sessions.contains("name"), "stdout: {human_sessions}"); + assert!( + human_sessions.contains("status"), + "stdout: {human_sessions}" + ); + assert!(human_sessions.contains("alpha"), "stdout: {human_sessions}"); + assert!( + human_sessions.contains("running"), + "stdout: {human_sessions}" + ); + assert!( + human_sessions.contains("/sessions/beta"), + "stdout: {human_sessions}" + ); + + let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list", "--json"]); + let sessions = sessions["sessions"].as_array().unwrap(); + let default_session = sessions + .iter() + .find(|session| session["name"] == "default") + .unwrap(); + let alpha_session = sessions + .iter() + .find(|session| session["name"] == "alpha") + .unwrap(); + let beta_session = sessions + .iter() + .find(|session| session["name"] == "beta") + .unwrap(); + assert_eq!(default_session["default"], true); + assert_eq!(default_session["running"], false); + assert_eq!(alpha_session["running"], true); + assert_eq!(beta_session["running"], true); + assert!(alpha_session["socket_path"] + .as_str() + .unwrap() + .ends_with("/sessions/alpha/herdr.sock")); + assert!(beta_session["session_dir"] + .as_str() + .unwrap() + .ends_with("/sessions/beta")); + + let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]); + assert_eq!(delete_running.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"), + "stderr: {}", + String::from_utf8_lossy(&delete_running.stderr) + ); + + let delete_default = run_named_cli( + &config_home, + &runtime_dir, + &["session", "delete", "default"], + ); + assert_eq!(delete_default.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&delete_default.stderr).contains("default session"), + "stderr: {}", + String::from_utf8_lossy(&delete_default.stderr) + ); + + let stopped_alpha = run_named_cli_json( + &config_home, + &runtime_dir, + &["session", "stop", "alpha", "--json"], + ); + assert_eq!(stopped_alpha["stopped"], true); + assert_eq!(stopped_alpha["session"]["running"], false); + + let deleted_alpha = run_named_cli_json( + &config_home, + &runtime_dir, + &["session", "delete", "alpha", "--json"], + ); + assert_eq!(deleted_alpha["deleted"], true); + assert!(!config_home + .join(app_dir_name()) + .join("sessions") + .join("alpha") + .exists()); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]); + drop(alpha); + drop(beta); + cleanup_test_base(&base); +} + +#[test] +fn integration_commands_run_locally_when_server_is_missing() { + let base = unique_test_dir(); + let home_dir = base.join("home"); + let extensions_dir = home_dir.join(".pi/agent/extensions"); + fs::create_dir_all(&extensions_dir).unwrap(); + + let runtime_dir = base.join("runtime"); + fs::create_dir_all(&runtime_dir).unwrap(); + register_runtime_dir(&runtime_dir); + let missing_socket = runtime_dir.join("missing.sock"); + + let expected_extension = extensions_dir.join("herdr-agent-state.ts"); + assert!( + !expected_extension.exists(), + "test setup should start without extension file" + ); + + let workspace_list = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["workspace", "list"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + assert_eq!(workspace_list.status.code(), Some(1)); + + let integration_install = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "install", "pi"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + assert_eq!(integration_install.status.code(), Some(0)); + assert!( + expected_extension.exists(), + "integration install should write local files without a server" + ); + + let integration_status = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "status"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + assert_eq!(integration_status.status.code(), Some(0)); + let status_stdout = String::from_utf8_lossy(&integration_status.stdout); + assert!(status_stdout.contains("pi: current (v6)")); + assert!(status_stdout.contains("claude: not installed")); + + let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "uninstall", "pi"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + assert_eq!(integration_uninstall.status.code(), Some(0)); + assert!( + !expected_extension.exists(), + "integration uninstall should remove local files without a server" + ); + + cleanup_test_base(&base); +} + +#[test] +fn integration_status_outdated_only_prints_action_for_legacy_install() { + let base = unique_test_dir(); + let home_dir = base.join("home"); + let extensions_dir = home_dir.join(".pi/agent/extensions"); + fs::create_dir_all(&extensions_dir).unwrap(); + fs::write( + extensions_dir.join("herdr-agent-state.ts"), + "// legacy herdr integration\n", + ) + .unwrap(); + + let runtime_dir = base.join("runtime"); + fs::create_dir_all(&runtime_dir).unwrap(); + register_runtime_dir(&runtime_dir); + let missing_socket = runtime_dir.join("missing.sock"); + + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "status", "--outdated-only"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("installed herdr integrations need updating")); + assert!(stderr.contains("herdr integration install pi")); + + cleanup_test_base(&base); +} + +#[test] +fn integration_status_rejects_unknown_flags() { + let base = unique_test_dir(); + let home_dir = base.join("home"); + fs::create_dir_all(&home_dir).unwrap(); + let runtime_dir = base.join("runtime"); + fs::create_dir_all(&runtime_dir).unwrap(); + register_runtime_dir(&runtime_dir); + let missing_socket = runtime_dir.join("missing.sock"); + + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "status", "--wat"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + + cleanup_test_base(&base); +} + +#[test] +fn status_commands_report_client_and_server_versions() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let full = run_cli(&socket_path, &["status"]); + assert!( + full.status.success(), + "stderr: {}", + String::from_utf8_lossy(&full.stderr) + ); + let full_stdout = String::from_utf8_lossy(&full.stdout); + assert!(full_stdout.contains("client:\n"), "stdout: {full_stdout}"); + assert!( + full_stdout.contains(&format!(" version: {}", env!("CARGO_PKG_VERSION"))), + "stdout: {full_stdout}" + ); + assert!( + full_stdout.contains(" protocol: 17"), + "stdout: {full_stdout}" + ); + assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); + assert!( + full_stdout.contains(" status: running"), + "stdout: {full_stdout}" + ); + assert!( + full_stdout.contains(" compatible: yes"), + "stdout: {full_stdout}" + ); + assert!( + full_stdout.contains(" restart_needed: no"), + "stdout: {full_stdout}" + ); + assert!( + full_stdout.contains(&socket_path.display().to_string()), + "stdout: {full_stdout}" + ); + + let server = run_cli(&socket_path, &["status", "server"]); + assert!(server.status.success()); + let server_stdout = String::from_utf8_lossy(&server.stdout); + assert!( + server_stdout.contains("status: running"), + "stdout: {server_stdout}" + ); + assert!( + server_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), + "stdout: {server_stdout}" + ); + assert!( + server_stdout.contains("protocol: 17"), + "stdout: {server_stdout}" + ); + + let client = run_cli(&socket_path, &["status", "client"]); + assert!(client.status.success()); + let client_stdout = String::from_utf8_lossy(&client.stdout); + assert!( + client_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), + "stdout: {client_stdout}" + ); + assert!( + client_stdout.contains("protocol: 17"), + "stdout: {client_stdout}" + ); + assert!( + client_stdout.contains("binary: "), + "stdout: {client_stdout}" + ); + + let full_json = run_cli_json(&socket_path, &["status", "--json"]); + assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(full_json["client"]["protocol"], 17); + assert_eq!(full_json["server"]["status"], "running"); + assert_eq!(full_json["server"]["running"], true); + assert_eq!(full_json["server"]["compatible"], true); + assert_eq!( + full_json["server"]["socket"], + socket_path.display().to_string() + ); + assert_eq!(full_json["server"]["restart_needed"], false); + assert_eq!(full_json["update"]["restart_needed"], false); + + let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); + assert_eq!(server_json["status"], "running"); + assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(server_json["protocol"], 17); + assert_eq!(server_json["compatible"], true); + + let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); + assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(client_json["protocol"], 17); + assert!(client_json["binary"] + .as_str() + .is_some_and(|path| !path.is_empty())); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn status_reports_not_running_when_server_socket_is_missing() { + let base = unique_test_dir(); + let runtime_dir = base.join("runtime"); + fs::create_dir_all(&runtime_dir).unwrap(); + register_runtime_dir(&runtime_dir); + let socket_path = runtime_dir.join("missing.sock"); + + let status = run_cli(&socket_path, &["status"]); + assert!(status.status.success()); + let stdout = String::from_utf8_lossy(&status.stdout); + assert!(stdout.contains(" status: not running"), "stdout: {stdout}"); + assert!(stdout.contains(" restart_needed: no"), "stdout: {stdout}"); + assert!( + stdout.contains(&socket_path.display().to_string()), + "stdout: {stdout}" + ); + + let status_json = run_cli_json(&socket_path, &["status", "--json"]); + assert_eq!(status_json["server"]["status"], "not_running"); + assert_eq!(status_json["server"]["running"], false); + assert_eq!( + status_json["server"]["socket"], + socket_path.display().to_string() + ); + assert_eq!(status_json["server"]["restart_needed"], false); + assert_eq!(status_json["update"]["restart_needed"], false); + + cleanup_test_base(&base); +} + +#[test] +fn server_stop_command_shuts_down_running_server() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + + let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let stopped = run_cli(&socket_path, &["server", "stop"]); + assert!( + stopped.status.success(), + "stderr: {}", + String::from_utf8_lossy(&stopped.stderr) + ); + assert!( + stopped.stdout.is_empty(), + "server stop should not print stdout: {}", + String::from_utf8_lossy(&stopped.stdout) + ); + assert!( + !socket_path.exists() || UnixStream::connect(&socket_path).is_err(), + "api socket should be removed or stale before server stop returns" + ); + assert!( + !client_socket.exists() || UnixStream::connect(&client_socket).is_err(), + "client socket should be removed or stale before server stop returns" + ); + + let pid = herdr.child.process_id(); + let exit_status = herdr.child.wait().unwrap(); + unregister_spawned_herdr_pid(pid); + assert!(exit_status.success(), "server stop should exit cleanly"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn server_stop_then_restart_restores_pane_history() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let marker = "PERSISTED_HISTORY_AFTER_STOP"; + + let mut herdr = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let created = run_cli_json( + &socket_path, + &[ + "workspace", + "create", + "--cwd", + base.to_str().expect("test path should be utf-8"), + "--label", + "history-restart", + ], + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .expect("workspace create should return root pane id") + .to_string(); + let sent = run_cli( + &socket_path, + &["pane", "send-text", &pane_id, &format!("echo {marker}\n")], + ); + assert!( + sent.status.success(), + "stderr: {}", + String::from_utf8_lossy(&sent.stderr) + ); + assert!( + wait_until(Duration::from_secs(3), Duration::from_millis(25), || { + pane_read_recent_contains(&socket_path, &pane_id, marker) + }), + "pane should contain marker before server stop" + ); + + let stopped = run_cli(&socket_path, &["server", "stop"]); + assert!( + stopped.status.success(), + "stderr: {}", + String::from_utf8_lossy(&stopped.stderr) + ); + + let pid = herdr.child.process_id(); + let exit_status = herdr.child.wait().unwrap(); + unregister_spawned_herdr_pid(pid); + assert!(exit_status.success(), "server stop should exit cleanly"); + drop(herdr); + + let restarted = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); + let workspace_id = workspaces["result"]["workspaces"] + .as_array() + .expect("workspace.list should return workspaces") + .iter() + .find(|workspace| workspace["label"] == "history-restart") + .and_then(|workspace| workspace["workspace_id"].as_str()) + .expect("restored workspace should exist") + .to_string(); + let panes = run_cli_json( + &socket_path, + &["pane", "list", "--workspace", &workspace_id], + ); + let restored_pane_id = panes["result"]["panes"] + .as_array() + .expect("pane.list should return panes") + .first() + .and_then(|pane| pane["pane_id"].as_str()) + .expect("restored pane should exist") + .to_string(); + + assert!( + wait_until(Duration::from_secs(3), Duration::from_millis(25), || { + pane_read_recent_contains(&socket_path, &restored_pane_id, marker) + }), + "restarted server should restore saved pane history" + ); + + cleanup_spawned_herdr(restarted, base); +} + +#[test] +fn server_start_restores_legacy_session_through_api_identity() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let data_dir = config_home.join(app_dir_name()); + let pion_cwd = base.join("legacy-pion"); + let herdr_cwd = base.join("legacy-herdr"); + + fs::create_dir_all(&pion_cwd).unwrap(); + fs::create_dir_all(&herdr_cwd).unwrap(); + fs::create_dir_all(&data_dir).unwrap(); + let pion_cwd = pion_cwd.to_str().expect("test cwd should be UTF-8"); + let herdr_cwd = herdr_cwd.to_str().expect("test cwd should be UTF-8"); + let legacy_session = include_str!("../fixtures/session/legacy-pre-tabs-v2.json") + .replace("/tmp/pion", pion_cwd) + .replace("/tmp/herdr", herdr_cwd); + fs::write(data_dir.join("session.json"), legacy_session).unwrap(); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); + let restored_workspace = workspaces["result"]["workspaces"] + .as_array() + .expect("workspace.list should return workspaces") + .iter() + .find(|workspace| workspace["label"] == "legacy") + .expect("legacy workspace should restore"); + let workspace_id = restored_workspace["workspace_id"] + .as_str() + .expect("restored workspace should have public id") + .to_string(); + assert_eq!(restored_workspace["pane_count"], 2); + assert_eq!(restored_workspace["tab_count"], 1); + assert_eq!( + restored_workspace["active_tab_id"], + format!("{workspace_id}:t1") + ); + + let panes = run_cli_json( + &socket_path, + &["pane", "list", "--workspace", &workspace_id], + ); + let panes = panes["result"]["panes"] + .as_array() + .expect("pane.list should return panes"); + assert_eq!(panes.len(), 2); + let root_pane_id = format!("{workspace_id}:p1"); + let focused_pane_id = format!("{workspace_id}:p2"); + assert!(panes.iter().any(|pane| { + pane["pane_id"] == root_pane_id + && pane["tab_id"] == format!("{workspace_id}:t1") + && pane["cwd"] == pion_cwd + && pane["focused"] == false + })); + assert!(panes.iter().any(|pane| { + pane["pane_id"] == focused_pane_id + && pane["tab_id"] == format!("{workspace_id}:t1") + && pane["cwd"] == herdr_cwd + && pane["focused"] == true + })); + + let reported = run_cli( + &socket_path, + &[ + "pane", + "report-agent", + &focused_pane_id, + "--source", + "test", + "--agent", + "pi", + "--state", + "working", + ], + ); + assert!( + reported.status.success(), + "stderr: {}", + String::from_utf8_lossy(&reported.stderr) + ); + + let agents = run_cli_json(&socket_path, &["agent", "list"]); + let agents = agents["result"]["agents"] + .as_array() + .expect("agent.list should return agents"); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0]["pane_id"], focused_pane_id); + assert_eq!(agents[0]["workspace_id"], workspace_id); + assert_eq!(agents[0]["agent"], "pi"); + assert_eq!(agents[0]["agent_status"], "working"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli/surface.rs b/tests/cli/surface.rs new file mode 100644 index 00000000..2507804e --- /dev/null +++ b/tests/cli/surface.rs @@ -0,0 +1,603 @@ +use super::harness::*; + +#[test] +fn pane_run_sends_one_send_input_request_with_enter_key() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut first_stream, first_line) = accept_fake_cli_operation(&listener); + first_stream + .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) + .unwrap(); + first_stream.write_all(b"\n").unwrap(); + first_stream.flush().unwrap(); + + let mut second_line = None; + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_millis(250); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut second_stream, _)) => { + let mut line = String::new(); + let mut reader = BufReader::new(second_stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + second_stream + .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) + .unwrap(); + second_stream.write_all(b"\n").unwrap(); + second_stream.flush().unwrap(); + second_line = Some(line); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("second accept failed: {err}"), + } + } + + (first_line, second_line) + }); + + let run = run_cli(&socket_path, &["pane", "run", "1-1", "echo hello"]); + assert!( + run.status.success(), + "stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + + let (first_line, second_line) = server.join().unwrap(); + let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); + assert_eq!(first_request["method"], "pane.send_input"); + assert_eq!(first_request["params"]["pane_id"], "1-1"); + assert_eq!(first_request["params"]["text"], "echo hello"); + assert_eq!( + first_request["params"]["keys"], + serde_json::json!(["Enter"]) + ); + assert!( + second_line.is_none(), + "pane run sent an unexpected second request: {:?}", + second_line + ); + + cleanup_test_base(&base); +} + +#[test] +fn workspace_report_metadata_sends_token_patch() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut stream, line) = accept_fake_cli_operation(&listener); + stream + .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) + .unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + line + }); + + let run = run_cli( + &socket_path, + &[ + "workspace", + "report-metadata", + "2", + "--source", + "user:jj", + "--token", + "jj_status=2 changes", + "--token", + "summary=clean", + "--clear-token", + "old", + "--ttl-ms", + "5000", + ], + ); + assert!( + run.status.success(), + "stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + + let request: serde_json::Value = serde_json::from_str(&server.join().unwrap()).unwrap(); + assert_eq!(request["method"], "workspace.report_metadata"); + assert_eq!(request["params"]["workspace_id"], "2"); + assert_eq!(request["params"]["tokens"]["jj_status"], "2 changes"); + assert_eq!(request["params"]["tokens"]["summary"], "clean"); + assert!(request["params"]["tokens"]["old"].is_null()); + assert_eq!(request["params"]["ttl_ms"], 5000); + + cleanup_test_base(&base); +} + +#[test] +fn pane_report_metadata_sends_presentation_request() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut stream, line) = accept_fake_cli_operation(&listener); + stream + .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) + .unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + line + }); + + let run = run_cli( + &socket_path, + &[ + "pane", + "report-metadata", + "1-1", + "--source", + "user:claude-title", + "--agent", + "claude", + "--title", + "Refactor auth", + "--display-agent", + "Claude auth", + "--state-label", + "working=deep in the mines", + "--token", + "summary=reviewing auth", + "--token", + "model=opus", + "--clear-token", + "old", + "--ttl-ms", + "3600000", + ], + ); + assert!( + run.status.success(), + "stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + + let line = server.join().unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "pane.report_metadata"); + assert_eq!(request["params"]["pane_id"], "1-1"); + assert_eq!(request["params"]["source"], "user:claude-title"); + assert_eq!(request["params"]["agent"], "claude"); + assert!(request["params"]["applies_to_source"].is_null()); + assert_eq!(request["params"]["title"], "Refactor auth"); + assert_eq!(request["params"]["display_agent"], "Claude auth"); + assert_eq!( + request["params"]["state_labels"]["working"], + "deep in the mines" + ); + assert_eq!(request["params"]["tokens"]["summary"], "reviewing auth"); + assert_eq!(request["params"]["tokens"]["model"], "opus"); + assert!(request["params"]["tokens"]["old"].is_null()); + assert_eq!(request["params"]["ttl_ms"], 3_600_000); + + cleanup_test_base(&base); +} + +#[test] +fn pane_report_metadata_rejects_blank_source_before_socket_request() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("missing.sock"); + + let run = run_cli( + &socket_path, + &[ + "pane", + "report-metadata", + "1-1", + "--source", + " ", + "--token", + "summary=middleware", + ], + ); + + assert_eq!(run.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&run.stderr).contains("missing required --source"), + "stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + + cleanup_test_base(&base); +} + +#[test] +fn pane_report_metadata_rejects_blank_applies_to_source_before_socket_request() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("missing.sock"); + + let run = run_cli( + &socket_path, + &[ + "pane", + "report-metadata", + "1-1", + "--source", + "user:claude-title", + "--applies-to-source", + " ", + "--token", + "summary=middleware", + ], + ); + + assert_eq!(run.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&run.stderr).contains("missing value for --applies-to-source"), + "stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + + cleanup_test_base(&base); +} + +#[test] +fn help_commands_exit_successfully() { + let help_cases: &[&[&str]] = &[ + &["--help"], + &["agent", "wait", "--help"], + &["terminal", "session", "control", "-h"], + ]; + + for args in help_cases { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(*args) + .output() + .unwrap(); + assert!( + output.status.success(), + "herdr {} failed: status={:?} stdout={} stderr={}", + args.join(" "), + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn subcommand_help_explains_automation_semantics_without_a_server() { + let cases: &[(&[&str], &str)] = &[ + (&["agent", "wait", "--help"], "Without --until"), + ( + &["agent", "prompt", "--help"], + "first matching state observed after submission", + ), + (&["agent", "start", "--help"], "ready for input"), + ( + &["agent", "send-keys", "--help"], + "canonical Escape key name", + ), + ( + &["pane", "wait-output", "--help"], + "including existing output", + ), + ( + &["pane", "send-keys", "--help"], + "canonical Escape key name", + ), + ]; + + for (args, expected) in cases { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(*args) + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV") + .output() + .unwrap(); + assert!( + output.status.success(), + "herdr {} failed: status={:?} stdout={} stderr={}", + args.join(" "), + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(expected), + "herdr {} help did not contain {expected:?}: {stdout}", + args.join(" ") + ); + } +} + +#[test] +fn removed_wait_and_agent_send_commands_are_rejected() { + let wait = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["wait", "output", "w1:p1", "--match", "ready"]) + .output() + .unwrap(); + assert_eq!(wait.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&wait.stderr).contains("unknown command: wait")); + let help = Command::new(env!("CARGO_BIN_EXE_herdr")) + .arg("--help") + .output() + .unwrap(); + assert!(!String::from_utf8_lossy(&help.stdout).contains("herdr wait <subcommand>")); + + let send = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["agent", "send", "reviewer", "hello"]) + .output() + .unwrap(); + assert_eq!(send.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&send.stderr); + assert!(stderr.contains("herdr agent send-keys")); + assert!(!stderr.contains("herdr agent send <")); +} + +#[test] +fn agent_cli_rejects_invalid_wait_and_rename_grammar_locally() { + for args in [ + &["agent", "wait", "reviewer", "--until", "finished"][..], + &["agent", "wait", "reviewer", "--timeout", "later"][..], + &[ + "agent", "prompt", "reviewer", "work", "--wait", "--until", "finished", + ][..], + &[ + "agent", + "prompt", + "reviewer", + "work", + "--wait", + "--timeout", + "later", + ][..], + &[ + "agent", + "start", + "reviewer", + "--kind", + "pi", + "--pane", + "w1:p1", + "--timeout", + "later", + ][..], + &["agent", "rename", "reviewer"][..], + &["agent", "rename", "reviewer", "worker", "--clear"][..], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(args) + .env("HERDR_SOCKET_PATH", "/nonexistent/herdr.sock") + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "herdr {}: stdout={} stderr={}", + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn completion_command_prints_zsh_script_without_session_startup() { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["completion", "zsh"]) + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV") + .output() + .unwrap(); + + assert!( + output.status.success(), + "status={:?} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("#compdef herdr"), "stdout: {stdout}"); + assert!( + !stdout.contains("--cwd=[]"), + "zsh completions should not suggest equals-style values unsupported by most manual parsers: {stdout}" + ); + assert!( + !stdout.contains("--direction=[]"), + "zsh completions should not suggest equals-style direction values: {stdout}" + ); + assert!( + !stdout.contains("live-handoff"), + "internal server handoff command should not be completed: {stdout}" + ); +} + +#[test] +fn root_help_hides_explicit_client_command() { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .arg("--help") + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("herdr client"), + "root help should not advertise the internal client command: {stdout}" + ); +} + +#[test] +fn root_help_advertises_api_schema_command_group() { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .arg("--help") + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("herdr api <subcommand>"), + "root help should advertise the api command group: {stdout}" + ); +} + +#[test] +fn api_schema_default_output_is_a_short_summary() { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["api", "schema"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Herdr API schema"), "stdout: {stdout}"); + assert!( + stdout.contains("Use `herdr api schema --json`"), + "stdout: {stdout}" + ); + assert!( + stdout.len() < 400, + "summary should stay small enough for terminal output: {stdout}" + ); +} + +#[test] +fn api_schema_json_prints_bundled_schema() { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["api", "schema", "--json"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(schema + .get("protocol") + .and_then(serde_json::Value::as_u64) + .is_some_and(|protocol| protocol > 0)); + assert_eq!( + schema + .get("schemas") + .and_then(serde_json::Value::as_object) + .map(serde_json::Map::len), + Some(5) + ); +} + +#[test] +fn api_snapshot_prints_live_session_snapshot() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn({ + let socket_path = socket_path.clone(); + move || { + let (mut stream, line) = accept_fake_cli_operation(&listener); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "session.snapshot"); + assert_eq!(request["id"], "cli:api:snapshot"); + + let response = serde_json::json!({ + "id": "cli:api:snapshot", + "result": { + "type": "ok", + "marker": "snapshot-passthrough" + } + }); + writeln!(stream, "{response}").unwrap(); + stream.flush().unwrap(); + let _ = fs::remove_file(socket_path); + } + }); + + let value = run_cli_json(&socket_path, &["api", "snapshot"]); + + assert_eq!(value["result"]["marker"], "snapshot-passthrough"); + server.join().unwrap(); + cleanup_test_base(&base); +} + +#[test] +fn api_schema_output_writes_bundled_schema_to_file() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let schema_path = base.join("herdr-api.schema.json"); + + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["api", "schema", "--output"]) + .arg(&schema_path) + .output() + .unwrap(); + + assert!(output.status.success()); + assert!( + String::from_utf8_lossy(&output.stdout).contains("wrote API schema"), + "stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + let schema: serde_json::Value = + serde_json::from_slice(&fs::read(&schema_path).unwrap()).unwrap(); + assert!(schema + .get("protocol") + .and_then(serde_json::Value::as_u64) + .is_some_and(|protocol| protocol > 0)); + + cleanup_test_base(&base); +} + +#[test] +fn explicit_client_command_respects_nested_guard() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .arg("client") + .env("HERDR_ENV", "1") + .env("XDG_CONFIG_HOME", &base) + .env_remove("HERDR_CONFIG_PATH") + .output() + .unwrap(); + + cleanup_test_base(&base); + + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("nested herdr is disabled by default"), + "client should fail at the nested guard before connecting: {stderr}" + ); +} + +#[test] +fn removed_show_changelog_flag_fails_before_nested_guard() { + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .arg("--show-changelog") + .env("HERDR_ENV", "1") + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("unknown option: --show-changelog"), + "stderr: {stderr}" + ); + assert!( + !stderr.contains("nested herdr"), + "unknown flag should be rejected before nested guard: {stderr}" + ); +} diff --git a/tests/cli/workspace.rs b/tests/cli/workspace.rs new file mode 100644 index 00000000..8c435b2d --- /dev/null +++ b/tests/cli/workspace.rs @@ -0,0 +1,663 @@ +use super::harness::*; + +#[test] +fn workspace_and_pane_management_commands_work() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let reloaded = run_cli(&socket_path, &["server", "reload-config"]); + assert!( + reloaded.status.success(), + "stderr: {}", + String::from_utf8_lossy(&reloaded.stderr) + ); + let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap(); + assert_eq!(reload_json["result"]["type"], "config_reload"); + assert_eq!(reload_json["result"]["status"], "applied"); + + let listed = run_cli(&socket_path, &["workspace", "list"]); + assert!(listed.status.success()); + let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + assert_eq!(listed_json["result"]["type"], "workspace_list"); + assert_eq!( + listed_json["result"]["workspaces"] + .as_array() + .unwrap() + .len(), + 0 + ); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + let workspace_id = created_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + + let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]); + assert!(panes.status.success()); + let panes_json: serde_json::Value = serde_json::from_slice(&panes.stdout).unwrap(); + assert_eq!(panes_json["result"]["panes"].as_array().unwrap().len(), 1); + + let split = run_cli( + &socket_path, + &["pane", "split", "1-1", "--direction", "right"], + ); + assert!( + split.status.success(), + "stderr: {}", + String::from_utf8_lossy(&split.stderr) + ); + let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap(); + let split_pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap(); + + let fetched = run_cli(&socket_path, &["pane", "get", split_pane_id]); + assert!(fetched.status.success()); + let fetched_json: serde_json::Value = serde_json::from_slice(&fetched.stdout).unwrap(); + assert_eq!(fetched_json["result"]["pane"]["pane_id"], split_pane_id); + + let closed = run_cli(&socket_path, &["pane", "close", split_pane_id]); + assert!(closed.status.success()); + let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); + assert_eq!(closed_json["result"]["type"], "ok"); + + let renamed = run_cli( + &socket_path, + &["workspace", "rename", &workspace_id, "demo"], + ); + assert!(renamed.status.success()); + let renamed_json: serde_json::Value = serde_json::from_slice(&renamed.stdout).unwrap(); + assert_eq!(renamed_json["result"]["workspace"]["label"], "demo"); + + let focused = run_cli(&socket_path, &["workspace", "focus", &workspace_id]); + assert!(focused.status.success()); + + let closed_workspace = run_cli(&socket_path, &["workspace", "close", &workspace_id]); + assert!(closed_workspace.status.success()); + let closed_workspace_json: serde_json::Value = + serde_json::from_slice(&closed_workspace.stdout).unwrap(); + assert_eq!(closed_workspace_json["result"]["type"], "ok"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn worktree_management_commands_work() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let repo = base.join("repo"); + let checkout = base.join("checkout"); + create_committed_repo(&repo); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let branch = "worktree/cli-wrapper"; + let created = run_cli_json( + &socket_path, + &[ + "worktree", + "create", + "--cwd", + repo.to_str().unwrap(), + "--branch", + branch, + "--path", + checkout.to_str().unwrap(), + "--json", + ], + ); + assert_eq!(created["result"]["type"], "worktree_created"); + assert_eq!(created["result"]["worktree"]["branch"], branch); + let child_workspace_id = created["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + assert!(checkout.join("README.md").exists()); + + let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); + let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap(); + let parent_workspace_id = workspace_list + .iter() + .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false)) + .and_then(|workspace| workspace["workspace_id"].as_str()) + .unwrap() + .to_string(); + assert!(workspace_list.iter().any(|workspace| { + workspace["workspace_id"].as_str() == Some(child_workspace_id.as_str()) + && workspace["worktree"]["is_linked_worktree"].as_bool() == Some(true) + })); + + let listed = run_cli_json( + &socket_path, + &[ + "worktree", + "list", + "--workspace", + &parent_workspace_id, + "--json", + ], + ); + let listed_entry = listed["result"]["worktrees"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["branch"].as_str() == Some(branch)) + .unwrap(); + assert_eq!( + listed_entry["open_workspace_id"].as_str(), + Some(child_workspace_id.as_str()) + ); + + let opened = run_cli_json( + &socket_path, + &[ + "worktree", + "open", + "--workspace", + &parent_workspace_id, + "--branch", + branch, + "--json", + ], + ); + assert_eq!(opened["result"]["type"], "worktree_opened"); + assert_eq!(opened["result"]["already_open"], true); + assert_eq!( + opened["result"]["workspace"]["workspace_id"].as_str(), + Some(child_workspace_id.as_str()) + ); + + fs::write(checkout.join("README.md"), "dirty\n").unwrap(); + let safe_remove = run_cli( + &socket_path, + &[ + "worktree", + "remove", + "--workspace", + &child_workspace_id, + "--json", + ], + ); + assert_eq!(safe_remove.status.code(), Some(1)); + let safe_remove_json: serde_json::Value = serde_json::from_slice(&safe_remove.stderr).unwrap(); + assert_eq!( + safe_remove_json["error"]["code"], + "dirty_worktree_requires_force" + ); + assert!(checkout.exists()); + + let force_removed = run_cli_json( + &socket_path, + &[ + "worktree", + "remove", + "--workspace", + &child_workspace_id, + "--force", + "--json", + ], + ); + assert_eq!(force_removed["result"]["type"], "worktree_removed"); + assert_eq!(force_removed["result"]["forced"], true); + assert!(!checkout.exists()); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn forced_worktree_remove_terminates_processes_inside_checkout() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let repo = base.join("repo"); + let checkout = base.join("checkout-with-process"); + create_committed_repo(&repo); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli_json( + &socket_path, + &[ + "worktree", + "create", + "--cwd", + repo.to_str().unwrap(), + "--branch", + "worktree/force-process", + "--path", + checkout.to_str().unwrap(), + "--json", + ], + ); + let child_workspace_id = created["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let pid_file = base.join("worktree-remove-force.pid"); + let command = format!( + "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", + pid_file.display() + ); + let ran = run_cli(&socket_path, &["pane", "run", &pane_id, &command]); + assert!( + ran.status.success(), + "stderr: {}", + String::from_utf8_lossy(&ran.stderr) + ); + let pid = wait_for_pid_file(&pid_file, Duration::from_secs(5)).unwrap_or_else(|err| { + panic!("failed to read pane child pid: {err}"); + }); + assert!(process_exists(pid), "child process was not running"); + + let removed = run_cli_json( + &socket_path, + &[ + "worktree", + "remove", + "--workspace", + &child_workspace_id, + "--force", + "--json", + ], + ); + assert_eq!(removed["result"]["type"], "worktree_removed"); + assert!(wait_for_pid_exit(pid, Duration::from_secs(3))); + assert!(!checkout.exists()); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn worktree_open_existing_checkout_by_path_and_branch() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let repo = base.join("repo"); + let checkout = base.join("external-checkout"); + create_committed_repo(&repo); + let branch = "worktree/cli-open-existing"; + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + branch, + checkout.to_str().unwrap(), + "HEAD", + ], + ); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let opened = run_cli_json_in_dir( + &socket_path, + &[ + "worktree", + "open", + "--cwd", + "repo", + "--path", + "external-checkout", + "--json", + ], + &base, + ); + assert_eq!(opened["result"]["type"], "worktree_opened"); + assert_eq!(opened["result"]["already_open"], false); + assert_eq!(opened["result"]["worktree"]["branch"], branch); + assert_eq!( + opened["result"]["workspace"]["worktree"]["is_linked_worktree"], + true + ); + let child_workspace_id = opened["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + + let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); + let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap(); + let parent_workspace_id = workspace_list + .iter() + .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false)) + .and_then(|workspace| workspace["workspace_id"].as_str()) + .unwrap() + .to_string(); + + let listed = run_cli_json( + &socket_path, + &[ + "worktree", + "list", + "--workspace", + &parent_workspace_id, + "--json", + ], + ); + let listed_entry = listed["result"]["worktrees"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["branch"].as_str() == Some(branch)) + .unwrap(); + assert_eq!( + listed_entry["open_workspace_id"].as_str(), + Some(child_workspace_id.as_str()) + ); + + let reopened = run_cli_json( + &socket_path, + &[ + "worktree", + "open", + "--workspace", + &parent_workspace_id, + "--branch", + branch, + "--json", + ], + ); + assert_eq!(reopened["result"]["type"], "worktree_opened"); + assert_eq!(reopened["result"]["already_open"], true); + assert_eq!( + reopened["result"]["workspace"]["workspace_id"].as_str(), + Some(child_workspace_id.as_str()) + ); + + let removed = run_cli_json( + &socket_path, + &[ + "worktree", + "remove", + "--workspace", + &child_workspace_id, + "--force", + "--json", + ], + ); + assert_eq!(removed["result"]["type"], "worktree_removed"); + + cleanup_spawned_herdr(herdr, base); +} + +#[test] +fn config_check_reports_invalid_config_without_server() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let config_dir = config_home.join(app_dir_name()); + fs::create_dir_all(&config_dir).unwrap(); + fs::write( + config_dir.join("config.toml"), + "[keys\nnew_workspace = \"g\"\n", + ) + .unwrap(); + + let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); + + assert_eq!(checked.status.code(), Some(1)); + assert!( + checked.stderr.is_empty(), + "stderr: {}", + String::from_utf8_lossy(&checked.stderr) + ); + let stdout = String::from_utf8_lossy(&checked.stdout); + assert!(stdout.contains("config: issues found"), "{stdout}"); + assert!(stdout.contains("TOML parse error"), "{stdout}"); + assert!(stdout.contains("line 1"), "{stdout}"); + + fs::write( + config_dir.join("config.toml"), + "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\n", + ) + .unwrap(); + let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); + let stdout = String::from_utf8_lossy(&checked.stdout); + assert_eq!(checked.status.code(), Some(1)); + assert!(stdout.contains("sidebar_min_width (50)"), "{stdout}"); + assert!(stdout.contains("sidebar_max_width (30)"), "{stdout}"); + + cleanup_test_base(&base); +} + +#[test] +fn config_check_reports_unknown_keys_without_treating_comments_as_keys() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let config_dir = config_home.join(app_dir_name()); + fs::create_dir_all(&config_dir).unwrap(); + fs::write( + config_dir.join("config.toml"), + r#"made_up_toplevel = 1 +"made.up" = 2 +# commented_out_toplevel = true + +[ui] +mouse_capture = true +mouse_captur = false +# commented_out_ui_key = true + +[keys] +new_tabb = "ctrl+t" +"#, + ) + .unwrap(); + + let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); + + assert_eq!(checked.status.code(), Some(1)); + assert!( + checked.stderr.is_empty(), + "stderr: {}", + String::from_utf8_lossy(&checked.stderr) + ); + let stdout = String::from_utf8_lossy(&checked.stdout); + assert!(stdout.contains("config: issues found"), "{stdout}"); + assert!( + stdout.contains("unknown config key made_up_toplevel; ignoring key"), + "{stdout}" + ); + assert!( + stdout.contains("unknown config key \"made.up\"; ignoring key"), + "{stdout}" + ); + assert!( + stdout.contains("unknown config key ui.mouse_captur; ignoring key"), + "{stdout}" + ); + assert!( + stdout.contains("unknown config key keys.new_tabb; ignoring key"), + "{stdout}" + ); + assert!(!stdout.contains("commented_out"), "{stdout}"); + + cleanup_test_base(&base); +} + +#[test] +fn config_check_reports_unreadable_config_path() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let config_path = config_home.join(app_dir_name()).join("config.toml"); + fs::create_dir_all(&config_path).unwrap(); + + let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); + + assert_eq!(checked.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&checked.stdout); + assert!(stdout.contains("config: issues found"), "{stdout}"); + assert!(stdout.contains("config read error"), "{stdout}"); + assert!(stdout.contains("using defaults"), "{stdout}"); + + cleanup_test_base(&base); +} + +#[test] +fn config_check_reports_ok_when_config_is_missing() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + + let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); + + assert!(checked.status.success()); + let stdout = String::from_utf8_lossy(&checked.stdout); + assert!(stdout.contains("config: ok"), "{stdout}"); + + cleanup_test_base(&base); +} + +#[test] +fn config_check_rejects_json_output() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + + let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check", "--json"]); + + assert_eq!(checked.status.code(), Some(2)); + assert!(checked.stdout.is_empty()); + assert_eq!( + String::from_utf8_lossy(&checked.stderr), + "usage: herdr config check\n" + ); + + cleanup_test_base(&base); +} + +#[test] +fn worktree_cli_rejects_local_argument_errors_before_socket_use() { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("missing.sock"); + let cases: &[&[&str]] = &[ + &["worktree", "list", "--workspace", "1", "--cwd", "/tmp"], + &["worktree", "create", "--workspace", "1", "--cwd", "/tmp"], + &["worktree", "open", "--workspace", "1"], + &[ + "worktree", + "open", + "--workspace", + "1", + "--path", + "a", + "--branch", + "b", + ], + &[ + "worktree", + "open", + "--workspace", + "1", + "--cwd", + "/tmp", + "--branch", + "b", + ], + ]; + + for args in cases { + let output = run_cli(&socket_path, args); + assert_eq!( + output.status.code(), + Some(2), + "herdr {} should fail as local parse error; stdout={} stderr={}", + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + cleanup_test_base(&base); +} + +#[test] +fn tab_management_commands_work() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + let workspace_id = created_json["result"]["workspace"]["workspace_id"] + .as_str() + .unwrap() + .to_string(); + let first_tab_id = created_json["result"]["workspace"]["active_tab_id"] + .as_str() + .unwrap() + .to_string(); + + let created_tab = run_cli( + &socket_path, + &["tab", "create", "--workspace", &workspace_id], + ); + assert!(created_tab.status.success()); + let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap(); + let second_tab_id = created_tab_json["result"]["tab"]["tab_id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(second_tab_id, format!("{workspace_id}:t2")); + + let listed_tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]); + assert!(listed_tabs.status.success()); + let listed_tabs_json: serde_json::Value = serde_json::from_slice(&listed_tabs.stdout).unwrap(); + assert_eq!( + listed_tabs_json["result"]["tabs"].as_array().unwrap().len(), + 2 + ); + + let renamed_tab = run_cli(&socket_path, &["tab", "rename", &second_tab_id, "logs"]); + assert!(renamed_tab.status.success()); + let renamed_tab_json: serde_json::Value = serde_json::from_slice(&renamed_tab.stdout).unwrap(); + assert_eq!(renamed_tab_json["result"]["tab"]["label"], "logs"); + + let focused_tab = run_cli(&socket_path, &["tab", "focus", &first_tab_id]); + assert!(focused_tab.status.success()); + let focused_tab_json: serde_json::Value = serde_json::from_slice(&focused_tab.stdout).unwrap(); + assert_eq!(focused_tab_json["result"]["tab"]["tab_id"], first_tab_id); + + let tab_get = run_cli(&socket_path, &["tab", "get", &second_tab_id]); + assert!(tab_get.status.success()); + let tab_get_json: serde_json::Value = serde_json::from_slice(&tab_get.stdout).unwrap(); + assert_eq!(tab_get_json["result"]["tab"]["tab_id"], second_tab_id); + + let closed_tab = run_cli(&socket_path, &["tab", "close", &second_tab_id]); + assert!(closed_tab.status.success()); + let closed_tab_json: serde_json::Value = serde_json::from_slice(&closed_tab.stdout).unwrap(); + assert_eq!(closed_tab_json["result"]["type"], "ok"); + + cleanup_spawned_herdr(herdr, base); +} diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs deleted file mode 100644 index d218c230..00000000 --- a/tests/cli_wrapper.rs +++ /dev/null @@ -1,5651 +0,0 @@ -#![cfg(not(target_os = "macos"))] - -mod support; - -use std::fs; -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, CURRENT_PROTOCOL, -}; - -const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str = "example.worktree-bootstrap-ef876653ffc3"; - -fn unique_test_dir() -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - PathBuf::from(format!("/tmp/hcli-{}-{nanos}", std::process::id())) -} - -fn managed_github_plugin_dir(config_home: &Path) -> PathBuf { - config_home.join("herdr-dev").join("plugins").join("github") -} - -fn path_missing_or_empty(path: &Path) -> bool { - match fs::read_dir(path) { - Ok(mut entries) => entries.next().is_none(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, - Err(err) => panic!("failed to read {}: {err}", path.display()), - } -} - -fn run_git(repo: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .status() - .unwrap(); - assert!( - status.success(), - "git command failed: git -C {} {}", - repo.display(), - args.join(" ") - ); -} - -fn create_committed_repo(path: &Path) { - fs::create_dir_all(path).unwrap(); - run_git(path, &["init", "--quiet"]); - run_git(path, &["config", "user.email", "herdr@example.invalid"]); - run_git(path, &["config", "user.name", "Herdr Test"]); - fs::write(path.join("README.md"), "test\n").unwrap(); - run_git(path, &["add", "README.md"]); - run_git(path, &["commit", "--quiet", "-m", "initial"]); -} - -struct SpawnedHerdr { - _master: Box<dyn MasterPty + Send>, - child: Box<dyn Child + Send + Sync>, -} - -struct SpawnedServerProcess { - child: std::process::Child, -} - -impl Drop for SpawnedServerProcess { - fn drop(&mut self) { - let pid = self.child.id(); - let _ = self.child.kill(); - let _ = self.child.wait(); - unregister_spawned_herdr_pid(Some(pid)); - } -} - -impl Drop for SpawnedHerdr { - fn drop(&mut self) { - let pid = self.child.process_id(); - let _ = self.child.kill(); - - if let Some(pid) = pid { - let deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < deadline { - let mut status = 0; - let result = - unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; - if result == pid as libc::pid_t || result == -1 { - break; - } - thread::sleep(Duration::from_millis(20)); - } - - unregister_spawned_herdr_pid(Some(pid)); - } - } -} - -fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) { - drop(spawned); - cleanup_test_base(&base); -} - -fn wait_for_socket(path: &Path, timeout: Duration) { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if path.exists() && std::os::unix::net::UnixStream::connect(path).is_ok() { - return; - } - thread::sleep(Duration::from_millis(25)); - } - panic!("socket did not appear at {}", path.display()); -} - -fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr { - spawn_herdr_with_config( - config_home, - runtime_dir, - socket_path, - None, - "onboarding = false\n", - ) -} - -fn spawn_herdr_with_pane_history( - config_home: &Path, - runtime_dir: &Path, - socket_path: &Path, -) -> SpawnedHerdr { - spawn_herdr_with_config( - config_home, - runtime_dir, - socket_path, - None, - "onboarding = false\n[experimental]\npane_history = true\n", - ) -} - -fn app_dir_name() -> &'static str { - if cfg!(debug_assertions) { - "herdr-dev" - } else { - "herdr" - } -} - -fn named_session_socket(config_home: &Path, session: &str) -> PathBuf { - config_home - .join(app_dir_name()) - .join("sessions") - .join(session) - .join("herdr.sock") -} - -fn spawn_named_server( - config_home: &Path, - runtime_dir: &Path, - session: &str, -) -> SpawnedServerProcess { - fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join(app_dir_name()).join("config.toml"), - "onboarding = false\n", - ) - .unwrap(); - - let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); - command - .args(["--session", session, "server"]) - .env("XDG_CONFIG_HOME", config_home) - .env("XDG_RUNTIME_DIR", runtime_dir) - .env_remove("HERDR_SOCKET_PATH") - .env_remove("HERDR_CLIENT_SOCKET_PATH") - .env_remove("HERDR_ENV") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - let child = command.spawn().unwrap(); - register_spawned_herdr_pid(Some(child.id())); - SpawnedServerProcess { child } -} - -fn run_named_cli(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> std::process::Output { - run_named_cli_with_socket_override(config_home, runtime_dir, args, None) -} - -fn run_named_cli_with_socket_override( - config_home: &Path, - runtime_dir: &Path, - args: &[&str], - socket_override: Option<&Path>, -) -> std::process::Output { - run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, &[], socket_override) -} - -fn run_named_cli_with_env( - config_home: &Path, - runtime_dir: &Path, - args: &[&str], - envs: &[(&str, &Path)], -) -> std::process::Output { - run_named_cli_with_env_and_socket_override(config_home, runtime_dir, args, envs, None) -} - -fn run_named_cli_with_env_and_socket_override( - config_home: &Path, - runtime_dir: &Path, - args: &[&str], - envs: &[(&str, &Path)], - socket_override: Option<&Path>, -) -> std::process::Output { - 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_CLIENT_SOCKET_PATH") - .env_remove("HERDR_ENV"); - for (key, value) in envs { - command.env(key, value); - } - if let Some(socket_override) = socket_override { - command.env("HERDR_SOCKET_PATH", socket_override); - } else { - command.env_remove("HERDR_SOCKET_PATH"); - } - command.output().unwrap() -} - -fn run_named_cli_json(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> serde_json::Value { - let output = run_named_cli(config_home, runtime_dir, args); - assert!( - output.status.success(), - "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}", - args.join(" "), - output.status.code(), - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ); - serde_json::from_slice(&output.stdout).unwrap() -} - -fn spawn_herdr_with_path( - config_home: &Path, - runtime_dir: &Path, - socket_path: &Path, - path_override: Option<&Path>, -) -> SpawnedHerdr { - spawn_herdr_with_config( - config_home, - runtime_dir, - socket_path, - path_override, - "onboarding = false\n", - ) -} - -fn spawn_herdr_with_config( - config_home: &Path, - runtime_dir: &Path, - socket_path: &Path, - path_override: Option<&Path>, - config_toml: &str, -) -> SpawnedHerdr { - fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); - fs::create_dir_all(runtime_dir).unwrap(); - register_runtime_dir(runtime_dir); - fs::write( - config_home.join(app_dir_name()).join("config.toml"), - config_toml, - ) - .unwrap(); - - let pair = native_pty_system() - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .unwrap(); - - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); - cmd.arg("server"); - cmd.env("XDG_CONFIG_HOME", config_home); - cmd.env("XDG_RUNTIME_DIR", runtime_dir); - cmd.env("HERDR_SOCKET_PATH", socket_path); - cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); - cmd.env("SHELL", "/bin/sh"); - cmd.env_remove("HERDR_ENV"); - if let Some(path) = path_override { - cmd.env("PATH", path); - } - - let child = pair.slave.spawn_command(cmd).unwrap(); - register_spawned_herdr_pid(child.process_id()); - SpawnedHerdr { - _master: pair.master, - child, - } -} - -fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); - command.args(args); - command.env("HERDR_SOCKET_PATH", socket_path); - command.output().unwrap() -} - -fn run_cli_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); - command.args(args); - command.current_dir(current_dir); - command.env("HERDR_SOCKET_PATH", socket_path); - command.output().unwrap() -} - -fn pane_topology_snapshot(list_response: &serde_json::Value) -> Vec<serde_json::Value> { - list_response["result"]["panes"] - .as_array() - .unwrap() - .iter() - .map(|pane| { - serde_json::json!({ - "pane_id": pane["pane_id"], - "terminal_id": pane["terminal_id"], - "workspace_id": pane["workspace_id"], - "tab_id": pane["tab_id"], - "focused": pane["focused"], - }) - }) - .collect() -} - -fn run_cli_json(socket_path: &Path, args: &[&str]) -> serde_json::Value { - let output = run_cli(socket_path, args); - parse_cli_json_output(args, output) -} - -fn run_cli_json_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> serde_json::Value { - let output = run_cli_in_dir(socket_path, args, current_dir); - parse_cli_json_output(args, output) -} - -fn parse_cli_json_output(args: &[&str], output: std::process::Output) -> serde_json::Value { - assert!( - output.status.success(), - "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}", - args.join(" "), - output.status.code(), - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ); - - serde_json::from_slice(&output.stdout).unwrap_or_else(|err| { - panic!( - "failed to parse JSON response for `herdr {}`: {}\nstdout: {}\nstderr: {}", - args.join(" "), - err, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ) - }) -} - -fn wait_until(timeout: Duration, interval: Duration, mut condition: impl FnMut() -> bool) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if condition() { - return true; - } - thread::sleep(interval); - } - false -} - -fn pane_read_recent_contains(socket_path: &Path, pane_id: &str, expected: &str) -> bool { - let output = run_cli( - socket_path, - &["pane", "read", pane_id, "--source", "recent"], - ); - if !output.status.success() { - return false; - } - String::from_utf8_lossy(&output.stdout).contains(expected) -} - -fn process_exists(pid: u32) -> bool { - let result = unsafe { libc::kill(pid as i32, 0) }; - if result == 0 { - true - } else { - std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) - } -} - -fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if !process_exists(pid) { - return true; - } - thread::sleep(Duration::from_millis(25)); - } - !process_exists(pid) -} - -fn wait_for_pid_file(pid_file: &Path, timeout: Duration) -> Result<u32, String> { - const STABLE_PID_CONTENT_WINDOW: Duration = Duration::from_millis(250); - - let deadline = Instant::now() + timeout; - let mut last_contents = String::new(); - let mut stable_candidate: Option<(String, u32, Instant)> = None; - - while Instant::now() < deadline { - if let Ok(contents) = fs::read_to_string(pid_file) { - let trimmed = contents.trim().to_string(); - last_contents = contents; - - if let Ok(pid) = trimmed.parse::<u32>() { - match &stable_candidate { - Some((candidate_text, candidate_pid, stable_since)) - if candidate_text == &trimmed && *candidate_pid == pid => - { - if stable_since.elapsed() >= STABLE_PID_CONTENT_WINDOW { - return Ok(pid); - } - } - _ => { - stable_candidate = Some((trimmed, pid, Instant::now())); - } - } - } else { - stable_candidate = None; - } - } - - thread::sleep(Duration::from_millis(25)); - } - - Err(format!( - "pid file {} did not contain stable parseable pid before timeout; last contents={:?}", - pid_file.display(), - last_contents - )) -} - -#[test] -fn wait_for_pid_file_retries_until_pid_is_written() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let pid_file = base.join("delayed.pid"); - fs::write(&pid_file, "").unwrap(); - - let writer = thread::spawn({ - let pid_file = pid_file.clone(); - move || { - thread::sleep(Duration::from_millis(100)); - fs::write(pid_file, "424242\n").unwrap(); - } - }); - - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap(); - assert_eq!(pid, 424242); - - writer.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn wait_for_pid_file_errors_when_file_never_contains_pid() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let pid_file = base.join("empty.pid"); - fs::write(&pid_file, "").unwrap(); - - let err = wait_for_pid_file(&pid_file, Duration::from_millis(150)).unwrap_err(); - assert!( - err.contains("did not contain stable parseable pid"), - "unexpected error: {err}" - ); - - cleanup_test_base(&base); -} - -#[test] -fn wait_for_pid_file_rejects_unparseable_partial_write_until_stable_contents() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let pid_file = base.join("partial-race.pid"); - fs::write(&pid_file, "").unwrap(); - - let writer = thread::spawn({ - let pid_file = pid_file.clone(); - move || { - thread::sleep(Duration::from_millis(40)); - fs::write(&pid_file, "pid=").unwrap(); - thread::sleep(Duration::from_millis(40)); - fs::write(&pid_file, "pid=424242").unwrap(); - thread::sleep(Duration::from_millis(40)); - fs::write(&pid_file, "424242\n").unwrap(); - } - }); - - let start = Instant::now(); - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap(); - assert_eq!(pid, 424242); - assert!( - start.elapsed() >= Duration::from_millis(300), - "helper should wait for stable complete contents, elapsed={:?}", - start.elapsed() - ); - - writer.join().unwrap(); - cleanup_test_base(&base); -} - -fn send_request(socket_path: &Path, json: &str) -> serde_json::Value { - let mut stream = UnixStream::connect(socket_path).unwrap(); - stream.write_all(json.as_bytes()).unwrap(); - stream.write_all(b"\n").unwrap(); - stream.flush().unwrap(); - - let mut line = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut line).unwrap(); - serde_json::from_str(&line).unwrap() -} - -fn write_fake_pong( - stream: &mut UnixStream, - request: &serde_json::Value, - version: &str, - protocol: u32, -) { - writeln!( - stream, - "{}", - serde_json::json!({ - "id": request["id"], - "result": { - "type": "pong", - "version": version, - "protocol": protocol, - "capabilities": { - "live_handoff": true, - "detached_server_daemon": true - } - } - }) - ) - .unwrap(); - stream.flush().unwrap(); -} - -fn accept_fake_cli_operation(listener: &UnixListener) -> (UnixStream, String) { - loop { - let (mut stream, _) = listener.accept().unwrap(); - let mut line = String::new(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - reader.read_line(&mut line).unwrap(); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - if request["method"] != "ping" { - return (stream, line); - } - - write_fake_pong( - &mut stream, - &request, - "different-build-same-protocol", - CURRENT_PROTOCOL, - ); - } -} - -fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook( - "src/integration/assets/claude/herdr-agent-state.sh", - &[action], - hook_input, - ) -} - -fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook( - "src/integration/assets/codex/herdr-agent-state.sh", - &[action], - hook_input, - ) -} - -fn run_copilot_hook(hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook( - "src/integration/assets/copilot/herdr-agent-state.sh", - &[], - hook_input, - ) -} - -fn run_devin_hook( - action: &str, - hook_input: &str, - envs: &[(&str, &str)], -) -> Option<serde_json::Value> { - run_shell_hook_with_env( - "src/integration/assets/devin/herdr-agent-state.sh", - &[action], - hook_input, - envs, - ) -} - -fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> { - run_shell_hook_with_env(asset_path, args, hook_input, &[]) -} - -fn run_shell_hook_with_env( - asset_path: &str, - args: &[&str], - hook_input: &str, - envs: &[(&str, &str)], -) -> Option<serde_json::Value> { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - listener.set_nonblocking(true).unwrap(); - let deadline = Instant::now() + Duration::from_millis(700); - while Instant::now() < deadline { - match listener.accept() { - Ok((mut stream, _)) => { - let mut line = String::new(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - reader.read_line(&mut line).unwrap(); - let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#); - let _ = stream.write_all(b"\n"); - let _ = stream.flush(); - return Some(line); - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("accept failed: {err}"), - } - } - None - }); - - let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path); - let mut command = Command::new("bash"); - command - .arg(hook_path) - .args(args) - .env("HERDR_ENV", "1") - .env("HERDR_SOCKET_PATH", &socket_path) - .env("HERDR_PANE_ID", "p_test") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - for (key, value) in envs { - command.env(key, value); - } - let mut child = command.spawn().unwrap(); - let mut stdin = child.stdin.take().unwrap(); - stdin.write_all(hook_input.as_bytes()).unwrap(); - drop(stdin); - - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "hook failed: status={:?} stderr={} stdout={}", - output.status.code(), - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ); - - let request = server.join().unwrap(); - cleanup_test_base(&base); - request.map(|line| serde_json::from_str(&line).unwrap()) -} - -#[test] -fn claude_hook_ignores_state_actions() { - let subagent_input = r#"{"hook_event_name":"Notification","agent_id":"agent-abc123","agent_type":"Explore","notification_type":"permission_prompt"}"#; - - assert!(run_claude_hook("working", subagent_input).is_none()); - assert!(run_claude_hook("blocked", subagent_input).is_none()); -} - -#[test] -fn claude_hook_ignores_subagent_completion_reports() { - let subagent_input = - r#"{"hook_event_name":"SubagentStop","agent_id":"agent-abc123","agent_type":"Explore"}"#; - - assert!(run_claude_hook("working", subagent_input).is_none()); - assert!(run_claude_hook("idle", subagent_input).is_none()); - assert!(run_claude_hook("release", subagent_input).is_none()); -} - -#[test] -fn claude_hook_keeps_parent_agent_type_only_blocked() { - let request = run_claude_hook( - "blocked", - r#"{"hook_event_name":"PermissionRequest","agent_type":"Explore"}"#, - ); - - assert!(request.is_none()); -} - -#[test] -fn claude_hook_reports_session_id_from_stdin() { - let request = run_claude_hook( - "session", - r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#, - ) - .expect("session start should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent_session_id"], "claude-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn codex_hook_reports_session_id_from_stdin() { - let request = run_codex_hook( - "session", - r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#, - ) - .expect("codex hook should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent_session_id"], "codex-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn copilot_hook_reports_session_id_from_stdin() { - let request = run_copilot_hook( - r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#, - ) - .expect("copilot session start should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent"], "copilot"); - assert_eq!(request["params"]["agent_session_id"], "copilot-session"); - assert!(request["params"].get("state").is_none()); - - let camel = run_copilot_hook( - r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#, - ) - .expect("copilot camelCase session start should report session identity"); - - assert_eq!(camel["method"], "pane.report_agent_session"); - assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session"); - assert!(camel["params"].get("state").is_none()); -} - -#[test] -fn copilot_hook_does_not_report_lifecycle_state() { - for payload in [ - r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#, - r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#, - r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#, - r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#, - r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#, - ] { - assert!( - run_copilot_hook(payload).is_none(), - "copilot session-only hook should ignore lifecycle payload {payload}" - ); - } -} - -#[test] -fn devin_hook_ignores_prompt_session_list_fallback() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"UserPromptSubmit","prompt":"run tests"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, - ), - ], - ); - - assert!(request.is_none()); -} - -#[test] -fn devin_hook_reports_session_id_from_stdin_without_state() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"SessionStart","session_id":"devin-session","source":"startup"}"#, - &[("HERDR_DEVIN_LIST_JSON", r#"[{"id":"older-session"}]"#)], - ) - .expect("devin session start should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent"], "devin"); - assert_eq!(request["params"]["agent_session_id"], "devin-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn devin_hook_prefers_hook_session_id_over_list() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"PreToolUse","sessionId":"fresh-session","tool_name":"exec"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"older-session","working_directory":"/tmp/project"}]"#, - ), - ], - ) - .expect("devin tool hook should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent_session_id"], "fresh-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn devin_hook_reports_tool_session_from_list_without_state() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"older-session","working_directory":"/tmp/other"},{"id":"devin-session","working_directory":"/tmp/project"}]"#, - ), - ], - ) - .expect("devin tool hook should report session identity"); - - assert_eq!(request["method"], "pane.report_agent_session"); - assert_eq!(request["params"]["agent"], "devin"); - assert_eq!(request["params"]["agent_session_id"], "devin-session"); - assert!(request["params"].get("state").is_none()); -} - -#[test] -fn devin_hook_ignores_startup_session_list_fallback() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"SessionStart","source":"startup"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"stale-session","working_directory":"/tmp/project"}]"#, - ), - ], - ); - - assert!(request.is_none()); -} - -#[test] -fn devin_hook_ignores_non_matching_session_list_entries() { - let request = run_devin_hook( - "session", - r#"{"hook_event_name":"PreToolUse","tool_name":"exec"}"#, - &[ - ("DEVIN_PROJECT_DIR", "/tmp/project"), - ( - "HERDR_DEVIN_LIST_JSON", - r#"[{"id":"other-session","working_directory":"/tmp/other"}]"#, - ), - ], - ); - - assert!(request.is_none()); -} - -#[test] -fn pane_run_sends_one_send_input_request_with_enter_key() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut first_stream, first_line) = accept_fake_cli_operation(&listener); - first_stream - .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) - .unwrap(); - first_stream.write_all(b"\n").unwrap(); - first_stream.flush().unwrap(); - - let mut second_line = None; - listener.set_nonblocking(true).unwrap(); - let deadline = Instant::now() + Duration::from_millis(250); - while Instant::now() < deadline { - match listener.accept() { - Ok((mut second_stream, _)) => { - let mut line = String::new(); - let mut reader = BufReader::new(second_stream.try_clone().unwrap()); - reader.read_line(&mut line).unwrap(); - second_stream - .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) - .unwrap(); - second_stream.write_all(b"\n").unwrap(); - second_stream.flush().unwrap(); - second_line = Some(line); - break; - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("second accept failed: {err}"), - } - } - - (first_line, second_line) - }); - - let run = run_cli(&socket_path, &["pane", "run", "1-1", "echo hello"]); - assert!( - run.status.success(), - "stderr: {}", - String::from_utf8_lossy(&run.stderr) - ); - - let (first_line, second_line) = server.join().unwrap(); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - assert_eq!(first_request["method"], "pane.send_input"); - assert_eq!(first_request["params"]["pane_id"], "1-1"); - assert_eq!(first_request["params"]["text"], "echo hello"); - assert_eq!( - first_request["params"]["keys"], - serde_json::json!(["Enter"]) - ); - assert!( - second_line.is_none(), - "pane run sent an unexpected second request: {:?}", - second_line - ); - - cleanup_test_base(&base); -} - -#[test] -fn workspace_report_metadata_sends_token_patch() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut stream, line) = accept_fake_cli_operation(&listener); - stream - .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) - .unwrap(); - stream.write_all(b"\n").unwrap(); - stream.flush().unwrap(); - line - }); - - let run = run_cli( - &socket_path, - &[ - "workspace", - "report-metadata", - "2", - "--source", - "user:jj", - "--token", - "jj_status=2 changes", - "--token", - "summary=clean", - "--clear-token", - "old", - "--ttl-ms", - "5000", - ], - ); - assert!( - run.status.success(), - "stderr: {}", - String::from_utf8_lossy(&run.stderr) - ); - - let request: serde_json::Value = serde_json::from_str(&server.join().unwrap()).unwrap(); - assert_eq!(request["method"], "workspace.report_metadata"); - assert_eq!(request["params"]["workspace_id"], "2"); - assert_eq!(request["params"]["tokens"]["jj_status"], "2 changes"); - assert_eq!(request["params"]["tokens"]["summary"], "clean"); - assert!(request["params"]["tokens"]["old"].is_null()); - assert_eq!(request["params"]["ttl_ms"], 5000); - - cleanup_test_base(&base); -} - -#[test] -fn pane_report_metadata_sends_presentation_request() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut stream, line) = accept_fake_cli_operation(&listener); - stream - .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#) - .unwrap(); - stream.write_all(b"\n").unwrap(); - stream.flush().unwrap(); - line - }); - - let run = run_cli( - &socket_path, - &[ - "pane", - "report-metadata", - "1-1", - "--source", - "user:claude-title", - "--agent", - "claude", - "--title", - "Refactor auth", - "--display-agent", - "Claude auth", - "--state-label", - "working=deep in the mines", - "--token", - "summary=reviewing auth", - "--token", - "model=opus", - "--clear-token", - "old", - "--ttl-ms", - "3600000", - ], - ); - assert!( - run.status.success(), - "stderr: {}", - String::from_utf8_lossy(&run.stderr) - ); - - let line = server.join().unwrap(); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(request["method"], "pane.report_metadata"); - assert_eq!(request["params"]["pane_id"], "1-1"); - assert_eq!(request["params"]["source"], "user:claude-title"); - assert_eq!(request["params"]["agent"], "claude"); - assert!(request["params"]["applies_to_source"].is_null()); - assert_eq!(request["params"]["title"], "Refactor auth"); - assert_eq!(request["params"]["display_agent"], "Claude auth"); - assert_eq!( - request["params"]["state_labels"]["working"], - "deep in the mines" - ); - assert_eq!(request["params"]["tokens"]["summary"], "reviewing auth"); - assert_eq!(request["params"]["tokens"]["model"], "opus"); - assert!(request["params"]["tokens"]["old"].is_null()); - assert_eq!(request["params"]["ttl_ms"], 3_600_000); - - cleanup_test_base(&base); -} - -#[test] -fn pane_report_metadata_rejects_blank_source_before_socket_request() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("missing.sock"); - - let run = run_cli( - &socket_path, - &[ - "pane", - "report-metadata", - "1-1", - "--source", - " ", - "--token", - "summary=middleware", - ], - ); - - assert_eq!(run.status.code(), Some(2)); - assert!( - String::from_utf8_lossy(&run.stderr).contains("missing required --source"), - "stderr: {}", - String::from_utf8_lossy(&run.stderr) - ); - - cleanup_test_base(&base); -} - -#[test] -fn pane_report_metadata_rejects_blank_applies_to_source_before_socket_request() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("missing.sock"); - - let run = run_cli( - &socket_path, - &[ - "pane", - "report-metadata", - "1-1", - "--source", - "user:claude-title", - "--applies-to-source", - " ", - "--token", - "summary=middleware", - ], - ); - - assert_eq!(run.status.code(), Some(2)); - assert!( - String::from_utf8_lossy(&run.stderr).contains("missing value for --applies-to-source"), - "stderr: {}", - String::from_utf8_lossy(&run.stderr) - ); - - cleanup_test_base(&base); -} - -#[test] -fn help_commands_exit_successfully() { - let help_cases: &[&[&str]] = &[ - &["-h"], - &["--help"], - &["api", "-h"], - &["api", "schema", "-h"], - &["completion", "-h"], - &["status", "-h"], - &["server", "-h"], - &["workspace", "-h"], - &["worktree", "-h"], - &["tab", "-h"], - &["pane", "-h"], - &["wait", "-h"], - &["session", "-h"], - &["session", "attach", "-h"], - &["integration", "-h"], - ]; - - for args in help_cases { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(*args) - .output() - .unwrap(); - assert!( - output.status.success(), - "herdr {} failed: status={:?} stdout={} stderr={}", - args.join(" "), - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } -} - -#[test] -fn completion_command_prints_zsh_script_without_session_startup() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["completion", "zsh"]) - .env_remove("HERDR_SOCKET_PATH") - .env_remove("HERDR_CLIENT_SOCKET_PATH") - .env_remove("HERDR_ENV") - .output() - .unwrap(); - - assert!( - output.status.success(), - "status={:?} stderr={}", - output.status.code(), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("#compdef herdr"), "stdout: {stdout}"); - assert!( - stdout.contains("bash elvish fish powershell zsh"), - "stdout: {stdout}" - ); - assert!( - stdout.contains("'--direction[]:DIRECTION:(right down)'"), - "pane split/plugin pane open should complete --direction right|down without requiring equals: {stdout}" - ); - assert!( - !stdout.contains("--cwd=[]"), - "zsh completions should not suggest equals-style values unsupported by most manual parsers: {stdout}" - ); - assert!( - !stdout.contains("--direction=[]"), - "zsh completions should not suggest equals-style direction values: {stdout}" - ); - assert!( - !stdout.contains("live-handoff"), - "internal server handoff command should not be completed: {stdout}" - ); -} - -#[test] -fn root_help_hides_explicit_client_command() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .arg("--help") - .output() - .unwrap(); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - !stdout.contains("herdr client"), - "root help should not advertise the internal client command: {stdout}" - ); -} - -#[test] -fn root_help_advertises_api_schema_command_group() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .arg("--help") - .output() - .unwrap(); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("herdr api <subcommand>"), - "root help should advertise the api command group: {stdout}" - ); -} - -#[test] -fn api_schema_default_output_is_a_short_summary() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["api", "schema"]) - .output() - .unwrap(); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Herdr API schema"), "stdout: {stdout}"); - assert!( - stdout.contains("Use `herdr api schema --json`"), - "stdout: {stdout}" - ); - assert!( - stdout.len() < 400, - "summary should stay small enough for terminal output: {stdout}" - ); -} - -#[test] -fn api_schema_json_prints_bundled_schema() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["api", "schema", "--json"]) - .output() - .unwrap(); - - assert!(output.status.success()); - let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert!(schema - .get("protocol") - .and_then(serde_json::Value::as_u64) - .is_some_and(|protocol| protocol > 0)); - assert_eq!( - schema - .get("schemas") - .and_then(serde_json::Value::as_object) - .map(serde_json::Map::len), - Some(5) - ); -} - -#[test] -fn api_snapshot_prints_live_session_snapshot() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn({ - let socket_path = socket_path.clone(); - move || { - let (mut stream, line) = accept_fake_cli_operation(&listener); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(request["method"], "session.snapshot"); - assert_eq!(request["id"], "cli:api:snapshot"); - - let response = serde_json::json!({ - "id": "cli:api:snapshot", - "result": { - "type": "ok", - "marker": "snapshot-passthrough" - } - }); - writeln!(stream, "{response}").unwrap(); - stream.flush().unwrap(); - let _ = fs::remove_file(socket_path); - } - }); - - let value = run_cli_json(&socket_path, &["api", "snapshot"]); - - assert_eq!(value["result"]["marker"], "snapshot-passthrough"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn api_schema_output_writes_bundled_schema_to_file() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let schema_path = base.join("herdr-api.schema.json"); - - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["api", "schema", "--output"]) - .arg(&schema_path) - .output() - .unwrap(); - - assert!(output.status.success()); - assert!( - String::from_utf8_lossy(&output.stdout).contains("wrote API schema"), - "stdout: {}", - String::from_utf8_lossy(&output.stdout) - ); - let schema: serde_json::Value = - serde_json::from_slice(&fs::read(&schema_path).unwrap()).unwrap(); - assert!(schema - .get("protocol") - .and_then(serde_json::Value::as_u64) - .is_some_and(|protocol| protocol > 0)); - - cleanup_test_base(&base); -} - -#[test] -fn explicit_client_command_respects_nested_guard() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .arg("client") - .env("HERDR_ENV", "1") - .env("XDG_CONFIG_HOME", &base) - .env_remove("HERDR_CONFIG_PATH") - .output() - .unwrap(); - - cleanup_test_base(&base); - - assert_eq!(output.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("nested herdr is disabled by default"), - "client should fail at the nested guard before connecting: {stderr}" - ); -} - -#[test] -fn removed_show_changelog_flag_fails_before_nested_guard() { - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .arg("--show-changelog") - .env("HERDR_ENV", "1") - .output() - .unwrap(); - - assert_eq!(output.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown option: --show-changelog"), - "stderr: {stderr}" - ); - assert!( - !stderr.contains("nested herdr"), - "unknown flag should be rejected before nested guard: {stderr}" - ); -} - -#[test] -fn named_sessions_use_separate_servers_and_workspace_state() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - - let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); - let beta = spawn_named_server(&config_home, &runtime_dir, "beta"); - - wait_for_socket( - &named_session_socket(&config_home, "alpha"), - Duration::from_secs(5), - ); - wait_for_socket( - &named_session_socket(&config_home, "beta"), - Duration::from_secs(5), - ); - - run_named_cli_json( - &config_home, - &runtime_dir, - &[ - "--session", - "alpha", - "workspace", - "create", - "--label", - "alpha-ws", - "--no-focus", - ], - ); - run_named_cli_json( - &config_home, - &runtime_dir, - &[ - "--session", - "beta", - "workspace", - "create", - "--label", - "beta-ws", - "--no-focus", - ], - ); - - let alpha_list = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "alpha", "workspace", "list"], - ); - let beta_list = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "beta", "workspace", "list"], - ); - - let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"] - .as_array() - .unwrap() - .iter() - .map(|workspace| workspace["label"].as_str().unwrap()) - .collect(); - let beta_labels: Vec<_> = beta_list["result"]["workspaces"] - .as_array() - .unwrap() - .iter() - .map(|workspace| workspace["label"].as_str().unwrap()) - .collect(); - - assert_eq!(alpha_labels, vec!["alpha-ws"]); - assert_eq!(beta_labels, vec!["beta-ws"]); - - let beta_via_explicit_session = run_named_cli_with_socket_override( - &config_home, - &runtime_dir, - &["--session", "beta", "workspace", "list"], - Some(&named_session_socket(&config_home, "alpha")), - ); - assert!( - beta_via_explicit_session.status.success(), - "stderr: {}", - String::from_utf8_lossy(&beta_via_explicit_session.stderr) - ); - let beta_via_explicit_session: serde_json::Value = - serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap(); - let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"] - .as_array() - .unwrap() - .iter() - .map(|workspace| workspace["label"].as_str().unwrap()) - .collect(); - assert_eq!(labels_via_explicit, vec!["beta-ws"]); - - let human_sessions = run_named_cli(&config_home, &runtime_dir, &["session", "list"]); - assert!(human_sessions.status.success()); - let human_sessions = String::from_utf8_lossy(&human_sessions.stdout); - assert!(human_sessions.contains("name"), "stdout: {human_sessions}"); - assert!( - human_sessions.contains("status"), - "stdout: {human_sessions}" - ); - assert!(human_sessions.contains("alpha"), "stdout: {human_sessions}"); - assert!( - human_sessions.contains("running"), - "stdout: {human_sessions}" - ); - assert!( - human_sessions.contains("/sessions/beta"), - "stdout: {human_sessions}" - ); - - let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list", "--json"]); - let sessions = sessions["sessions"].as_array().unwrap(); - let default_session = sessions - .iter() - .find(|session| session["name"] == "default") - .unwrap(); - let alpha_session = sessions - .iter() - .find(|session| session["name"] == "alpha") - .unwrap(); - let beta_session = sessions - .iter() - .find(|session| session["name"] == "beta") - .unwrap(); - assert_eq!(default_session["default"], true); - assert_eq!(default_session["running"], false); - assert_eq!(alpha_session["running"], true); - assert_eq!(beta_session["running"], true); - assert!(alpha_session["socket_path"] - .as_str() - .unwrap() - .ends_with("/sessions/alpha/herdr.sock")); - assert!(beta_session["session_dir"] - .as_str() - .unwrap() - .ends_with("/sessions/beta")); - - let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]); - assert_eq!(delete_running.status.code(), Some(1)); - assert!( - String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"), - "stderr: {}", - String::from_utf8_lossy(&delete_running.stderr) - ); - - let delete_default = run_named_cli( - &config_home, - &runtime_dir, - &["session", "delete", "default"], - ); - assert_eq!(delete_default.status.code(), Some(1)); - assert!( - String::from_utf8_lossy(&delete_default.stderr).contains("default session"), - "stderr: {}", - String::from_utf8_lossy(&delete_default.stderr) - ); - - let stopped_alpha = run_named_cli_json( - &config_home, - &runtime_dir, - &["session", "stop", "alpha", "--json"], - ); - assert_eq!(stopped_alpha["stopped"], true); - assert_eq!(stopped_alpha["session"]["running"], false); - - let deleted_alpha = run_named_cli_json( - &config_home, - &runtime_dir, - &["session", "delete", "alpha", "--json"], - ); - assert_eq!(deleted_alpha["deleted"], true); - assert!(!config_home - .join(app_dir_name()) - .join("sessions") - .join("alpha") - .exists()); - - let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]); - drop(alpha); - drop(beta); - cleanup_test_base(&base); -} - -#[test] -fn integration_commands_run_locally_when_server_is_missing() { - let base = unique_test_dir(); - let home_dir = base.join("home"); - let extensions_dir = home_dir.join(".pi/agent/extensions"); - fs::create_dir_all(&extensions_dir).unwrap(); - - let runtime_dir = base.join("runtime"); - fs::create_dir_all(&runtime_dir).unwrap(); - register_runtime_dir(&runtime_dir); - let missing_socket = runtime_dir.join("missing.sock"); - - let expected_extension = extensions_dir.join("herdr-agent-state.ts"); - assert!( - !expected_extension.exists(), - "test setup should start without extension file" - ); - - let workspace_list = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["workspace", "list"]) - .env("HERDR_SOCKET_PATH", &missing_socket) - .env("HOME", &home_dir) - .output() - .unwrap(); - assert_eq!(workspace_list.status.code(), Some(1)); - - let integration_install = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["integration", "install", "pi"]) - .env("HERDR_SOCKET_PATH", &missing_socket) - .env("HOME", &home_dir) - .output() - .unwrap(); - assert_eq!(integration_install.status.code(), Some(0)); - assert!( - expected_extension.exists(), - "integration install should write local files without a server" - ); - - let integration_status = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["integration", "status"]) - .env("HERDR_SOCKET_PATH", &missing_socket) - .env("HOME", &home_dir) - .output() - .unwrap(); - assert_eq!(integration_status.status.code(), Some(0)); - let status_stdout = String::from_utf8_lossy(&integration_status.stdout); - assert!(status_stdout.contains("pi: current (v6)")); - assert!(status_stdout.contains("claude: not installed")); - - let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["integration", "uninstall", "pi"]) - .env("HERDR_SOCKET_PATH", &missing_socket) - .env("HOME", &home_dir) - .output() - .unwrap(); - assert_eq!(integration_uninstall.status.code(), Some(0)); - assert!( - !expected_extension.exists(), - "integration uninstall should remove local files without a server" - ); - - cleanup_test_base(&base); -} - -#[test] -fn integration_status_outdated_only_prints_action_for_legacy_install() { - let base = unique_test_dir(); - let home_dir = base.join("home"); - let extensions_dir = home_dir.join(".pi/agent/extensions"); - fs::create_dir_all(&extensions_dir).unwrap(); - fs::write( - extensions_dir.join("herdr-agent-state.ts"), - "// legacy herdr integration\n", - ) - .unwrap(); - - let runtime_dir = base.join("runtime"); - fs::create_dir_all(&runtime_dir).unwrap(); - register_runtime_dir(&runtime_dir); - let missing_socket = runtime_dir.join("missing.sock"); - - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["integration", "status", "--outdated-only"]) - .env("HERDR_SOCKET_PATH", &missing_socket) - .env("HOME", &home_dir) - .output() - .unwrap(); - - assert_eq!(output.status.code(), Some(0)); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("installed herdr integrations need updating")); - assert!(stderr.contains("herdr integration install pi")); - - cleanup_test_base(&base); -} - -#[test] -fn integration_status_rejects_unknown_flags() { - let base = unique_test_dir(); - let home_dir = base.join("home"); - fs::create_dir_all(&home_dir).unwrap(); - let runtime_dir = base.join("runtime"); - fs::create_dir_all(&runtime_dir).unwrap(); - register_runtime_dir(&runtime_dir); - let missing_socket = runtime_dir.join("missing.sock"); - - let output = Command::new(env!("CARGO_BIN_EXE_herdr")) - .args(["integration", "status", "--wat"]) - .env("HERDR_SOCKET_PATH", &missing_socket) - .env("HOME", &home_dir) - .output() - .unwrap(); - - assert_eq!(output.status.code(), Some(2)); - - cleanup_test_base(&base); -} - -#[test] -fn status_commands_report_client_and_server_versions() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let full = run_cli(&socket_path, &["status"]); - assert!( - full.status.success(), - "stderr: {}", - String::from_utf8_lossy(&full.stderr) - ); - let full_stdout = String::from_utf8_lossy(&full.stdout); - assert!(full_stdout.contains("client:\n"), "stdout: {full_stdout}"); - assert!( - full_stdout.contains(&format!(" version: {}", env!("CARGO_PKG_VERSION"))), - "stdout: {full_stdout}" - ); - assert!( - full_stdout.contains(" protocol: 17"), - "stdout: {full_stdout}" - ); - assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); - assert!( - full_stdout.contains(" status: running"), - "stdout: {full_stdout}" - ); - assert!( - full_stdout.contains(" compatible: yes"), - "stdout: {full_stdout}" - ); - assert!( - full_stdout.contains(" restart_needed: no"), - "stdout: {full_stdout}" - ); - assert!( - full_stdout.contains(&socket_path.display().to_string()), - "stdout: {full_stdout}" - ); - - let server = run_cli(&socket_path, &["status", "server"]); - assert!(server.status.success()); - let server_stdout = String::from_utf8_lossy(&server.stdout); - assert!( - server_stdout.contains("status: running"), - "stdout: {server_stdout}" - ); - assert!( - server_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), - "stdout: {server_stdout}" - ); - assert!( - server_stdout.contains("protocol: 17"), - "stdout: {server_stdout}" - ); - - let client = run_cli(&socket_path, &["status", "client"]); - assert!(client.status.success()); - let client_stdout = String::from_utf8_lossy(&client.stdout); - assert!( - client_stdout.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), - "stdout: {client_stdout}" - ); - assert!( - client_stdout.contains("protocol: 17"), - "stdout: {client_stdout}" - ); - assert!( - client_stdout.contains("binary: "), - "stdout: {client_stdout}" - ); - - let full_json = run_cli_json(&socket_path, &["status", "--json"]); - assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 17); - assert_eq!(full_json["server"]["status"], "running"); - assert_eq!(full_json["server"]["running"], true); - assert_eq!(full_json["server"]["compatible"], true); - assert_eq!( - full_json["server"]["socket"], - socket_path.display().to_string() - ); - assert_eq!(full_json["server"]["restart_needed"], false); - assert_eq!(full_json["update"]["restart_needed"], false); - - let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); - assert_eq!(server_json["status"], "running"); - assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 17); - assert_eq!(server_json["compatible"], true); - - let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); - assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 17); - assert!(client_json["binary"] - .as_str() - .is_some_and(|path| !path.is_empty())); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn status_reports_not_running_when_server_socket_is_missing() { - let base = unique_test_dir(); - let runtime_dir = base.join("runtime"); - fs::create_dir_all(&runtime_dir).unwrap(); - register_runtime_dir(&runtime_dir); - let socket_path = runtime_dir.join("missing.sock"); - - let status = run_cli(&socket_path, &["status"]); - assert!(status.status.success()); - let stdout = String::from_utf8_lossy(&status.stdout); - assert!(stdout.contains(" status: not running"), "stdout: {stdout}"); - assert!(stdout.contains(" restart_needed: no"), "stdout: {stdout}"); - assert!( - stdout.contains(&socket_path.display().to_string()), - "stdout: {stdout}" - ); - - let status_json = run_cli_json(&socket_path, &["status", "--json"]); - assert_eq!(status_json["server"]["status"], "not_running"); - assert_eq!(status_json["server"]["running"], false); - assert_eq!( - status_json["server"]["socket"], - socket_path.display().to_string() - ); - assert_eq!(status_json["server"]["restart_needed"], false); - assert_eq!(status_json["update"]["restart_needed"], false); - - cleanup_test_base(&base); -} - -#[test] -fn server_stop_command_shuts_down_running_server() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - - let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - wait_for_socket(&client_socket, Duration::from_secs(5)); - - let stopped = run_cli(&socket_path, &["server", "stop"]); - assert!( - stopped.status.success(), - "stderr: {}", - String::from_utf8_lossy(&stopped.stderr) - ); - assert!( - stopped.stdout.is_empty(), - "server stop should not print stdout: {}", - String::from_utf8_lossy(&stopped.stdout) - ); - assert!( - !socket_path.exists() || UnixStream::connect(&socket_path).is_err(), - "api socket should be removed or stale before server stop returns" - ); - assert!( - !client_socket.exists() || UnixStream::connect(&client_socket).is_err(), - "client socket should be removed or stale before server stop returns" - ); - - let pid = herdr.child.process_id(); - let exit_status = herdr.child.wait().unwrap(); - unregister_spawned_herdr_pid(pid); - assert!(exit_status.success(), "server stop should exit cleanly"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn server_stop_then_restart_restores_pane_history() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - let marker = "PERSISTED_HISTORY_AFTER_STOP"; - - let mut herdr = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - wait_for_socket(&client_socket, Duration::from_secs(5)); - - let created = run_cli_json( - &socket_path, - &[ - "workspace", - "create", - "--cwd", - base.to_str().expect("test path should be utf-8"), - "--label", - "history-restart", - ], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .expect("workspace create should return root pane id") - .to_string(); - let sent = run_cli( - &socket_path, - &["pane", "send-text", &pane_id, &format!("echo {marker}\n")], - ); - assert!( - sent.status.success(), - "stderr: {}", - String::from_utf8_lossy(&sent.stderr) - ); - assert!( - wait_until(Duration::from_secs(3), Duration::from_millis(25), || { - pane_read_recent_contains(&socket_path, &pane_id, marker) - }), - "pane should contain marker before server stop" - ); - - let stopped = run_cli(&socket_path, &["server", "stop"]); - assert!( - stopped.status.success(), - "stderr: {}", - String::from_utf8_lossy(&stopped.stderr) - ); - - let pid = herdr.child.process_id(); - let exit_status = herdr.child.wait().unwrap(); - unregister_spawned_herdr_pid(pid); - assert!(exit_status.success(), "server stop should exit cleanly"); - drop(herdr); - - let restarted = spawn_herdr_with_pane_history(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - wait_for_socket(&client_socket, Duration::from_secs(5)); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let workspace_id = workspaces["result"]["workspaces"] - .as_array() - .expect("workspace.list should return workspaces") - .iter() - .find(|workspace| workspace["label"] == "history-restart") - .and_then(|workspace| workspace["workspace_id"].as_str()) - .expect("restored workspace should exist") - .to_string(); - let panes = run_cli_json( - &socket_path, - &["pane", "list", "--workspace", &workspace_id], - ); - let restored_pane_id = panes["result"]["panes"] - .as_array() - .expect("pane.list should return panes") - .first() - .and_then(|pane| pane["pane_id"].as_str()) - .expect("restored pane should exist") - .to_string(); - - assert!( - wait_until(Duration::from_secs(3), Duration::from_millis(25), || { - pane_read_recent_contains(&socket_path, &restored_pane_id, marker) - }), - "restarted server should restore saved pane history" - ); - - cleanup_spawned_herdr(restarted, base); -} - -#[test] -fn server_start_restores_legacy_session_through_api_identity() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let client_socket = runtime_dir.join("herdr-client.sock"); - let data_dir = config_home.join(app_dir_name()); - let pion_cwd = base.join("legacy-pion"); - let herdr_cwd = base.join("legacy-herdr"); - - fs::create_dir_all(&pion_cwd).unwrap(); - fs::create_dir_all(&herdr_cwd).unwrap(); - fs::create_dir_all(&data_dir).unwrap(); - let pion_cwd = pion_cwd.to_str().expect("test cwd should be UTF-8"); - let herdr_cwd = herdr_cwd.to_str().expect("test cwd should be UTF-8"); - let legacy_session = include_str!("fixtures/session/legacy-pre-tabs-v2.json") - .replace("/tmp/pion", pion_cwd) - .replace("/tmp/herdr", herdr_cwd); - fs::write(data_dir.join("session.json"), legacy_session).unwrap(); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - wait_for_socket(&client_socket, Duration::from_secs(5)); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let restored_workspace = workspaces["result"]["workspaces"] - .as_array() - .expect("workspace.list should return workspaces") - .iter() - .find(|workspace| workspace["label"] == "legacy") - .expect("legacy workspace should restore"); - let workspace_id = restored_workspace["workspace_id"] - .as_str() - .expect("restored workspace should have public id") - .to_string(); - assert_eq!(restored_workspace["pane_count"], 2); - assert_eq!(restored_workspace["tab_count"], 1); - assert_eq!( - restored_workspace["active_tab_id"], - format!("{workspace_id}:t1") - ); - - let panes = run_cli_json( - &socket_path, - &["pane", "list", "--workspace", &workspace_id], - ); - let panes = panes["result"]["panes"] - .as_array() - .expect("pane.list should return panes"); - assert_eq!(panes.len(), 2); - let root_pane_id = format!("{workspace_id}:p1"); - let focused_pane_id = format!("{workspace_id}:p2"); - assert!(panes.iter().any(|pane| { - pane["pane_id"] == root_pane_id - && pane["tab_id"] == format!("{workspace_id}:t1") - && pane["cwd"] == pion_cwd - && pane["focused"] == false - })); - assert!(panes.iter().any(|pane| { - pane["pane_id"] == focused_pane_id - && pane["tab_id"] == format!("{workspace_id}:t1") - && pane["cwd"] == herdr_cwd - && pane["focused"] == true - })); - - let reported = run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &focused_pane_id, - "--source", - "test", - "--agent", - "pi", - "--state", - "working", - ], - ); - assert!( - reported.status.success(), - "stderr: {}", - String::from_utf8_lossy(&reported.stderr) - ); - - let agents = run_cli_json(&socket_path, &["agent", "list"]); - let agents = agents["result"]["agents"] - .as_array() - .expect("agent.list should return agents"); - assert_eq!(agents.len(), 1); - assert_eq!(agents[0]["pane_id"], focused_pane_id); - assert_eq!(agents[0]["workspace_id"], workspace_id); - assert_eq!(agents[0]["agent"], "pi"); - assert_eq!(agents[0]["agent_status"], "working"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn workspace_and_pane_management_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let reloaded = run_cli(&socket_path, &["server", "reload-config"]); - assert!( - reloaded.status.success(), - "stderr: {}", - String::from_utf8_lossy(&reloaded.stderr) - ); - let reload_json: serde_json::Value = serde_json::from_slice(&reloaded.stdout).unwrap(); - assert_eq!(reload_json["result"]["type"], "config_reload"); - assert_eq!(reload_json["result"]["status"], "applied"); - - let listed = run_cli(&socket_path, &["workspace", "list"]); - assert!(listed.status.success()); - let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); - assert_eq!(listed_json["result"]["type"], "workspace_list"); - assert_eq!( - listed_json["result"]["workspaces"] - .as_array() - .unwrap() - .len(), - 0 - ); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let workspace_id = created_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]); - assert!(panes.status.success()); - let panes_json: serde_json::Value = serde_json::from_slice(&panes.stdout).unwrap(); - assert_eq!(panes_json["result"]["panes"].as_array().unwrap().len(), 1); - - let split = run_cli( - &socket_path, - &["pane", "split", "1-1", "--direction", "right"], - ); - assert!( - split.status.success(), - "stderr: {}", - String::from_utf8_lossy(&split.stderr) - ); - let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap(); - let split_pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap(); - - let fetched = run_cli(&socket_path, &["pane", "get", split_pane_id]); - assert!(fetched.status.success()); - let fetched_json: serde_json::Value = serde_json::from_slice(&fetched.stdout).unwrap(); - assert_eq!(fetched_json["result"]["pane"]["pane_id"], split_pane_id); - - let closed = run_cli(&socket_path, &["pane", "close", split_pane_id]); - assert!(closed.status.success()); - let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); - assert_eq!(closed_json["result"]["type"], "ok"); - - let renamed = run_cli( - &socket_path, - &["workspace", "rename", &workspace_id, "demo"], - ); - assert!(renamed.status.success()); - let renamed_json: serde_json::Value = serde_json::from_slice(&renamed.stdout).unwrap(); - assert_eq!(renamed_json["result"]["workspace"]["label"], "demo"); - - let focused = run_cli(&socket_path, &["workspace", "focus", &workspace_id]); - assert!(focused.status.success()); - - let closed_workspace = run_cli(&socket_path, &["workspace", "close", &workspace_id]); - assert!(closed_workspace.status.success()); - let closed_workspace_json: serde_json::Value = - serde_json::from_slice(&closed_workspace.stdout).unwrap(); - assert_eq!(closed_workspace_json["result"]["type"], "ok"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn worktree_management_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let repo = base.join("repo"); - let checkout = base.join("checkout"); - create_committed_repo(&repo); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let branch = "worktree/cli-wrapper"; - let created = run_cli_json( - &socket_path, - &[ - "worktree", - "create", - "--cwd", - repo.to_str().unwrap(), - "--branch", - branch, - "--path", - checkout.to_str().unwrap(), - "--json", - ], - ); - assert_eq!(created["result"]["type"], "worktree_created"); - assert_eq!(created["result"]["worktree"]["branch"], branch); - let child_workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert!(checkout.join("README.md").exists()); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap(); - let parent_workspace_id = workspace_list - .iter() - .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false)) - .and_then(|workspace| workspace["workspace_id"].as_str()) - .unwrap() - .to_string(); - assert!(workspace_list.iter().any(|workspace| { - workspace["workspace_id"].as_str() == Some(child_workspace_id.as_str()) - && workspace["worktree"]["is_linked_worktree"].as_bool() == Some(true) - })); - - let listed = run_cli_json( - &socket_path, - &[ - "worktree", - "list", - "--workspace", - &parent_workspace_id, - "--json", - ], - ); - let listed_entry = listed["result"]["worktrees"] - .as_array() - .unwrap() - .iter() - .find(|entry| entry["branch"].as_str() == Some(branch)) - .unwrap(); - assert_eq!( - listed_entry["open_workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - let opened = run_cli_json( - &socket_path, - &[ - "worktree", - "open", - "--workspace", - &parent_workspace_id, - "--branch", - branch, - "--json", - ], - ); - assert_eq!(opened["result"]["type"], "worktree_opened"); - assert_eq!(opened["result"]["already_open"], true); - assert_eq!( - opened["result"]["workspace"]["workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - fs::write(checkout.join("README.md"), "dirty\n").unwrap(); - let safe_remove = run_cli( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--json", - ], - ); - assert_eq!(safe_remove.status.code(), Some(1)); - let safe_remove_json: serde_json::Value = serde_json::from_slice(&safe_remove.stderr).unwrap(); - assert_eq!( - safe_remove_json["error"]["code"], - "dirty_worktree_requires_force" - ); - assert!(checkout.exists()); - - let force_removed = run_cli_json( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--force", - "--json", - ], - ); - assert_eq!(force_removed["result"]["type"], "worktree_removed"); - assert_eq!(force_removed["result"]["forced"], true); - assert!(!checkout.exists()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn forced_worktree_remove_terminates_processes_inside_checkout() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let repo = base.join("repo"); - let checkout = base.join("checkout-with-process"); - create_committed_repo(&repo); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli_json( - &socket_path, - &[ - "worktree", - "create", - "--cwd", - repo.to_str().unwrap(), - "--branch", - "worktree/force-process", - "--path", - checkout.to_str().unwrap(), - "--json", - ], - ); - let child_workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let pid_file = base.join("worktree-remove-force.pid"); - let command = format!( - "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", - pid_file.display() - ); - let ran = run_cli(&socket_path, &["pane", "run", &pane_id, &command]); - assert!( - ran.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ran.stderr) - ); - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(5)).unwrap_or_else(|err| { - panic!("failed to read pane child pid: {err}"); - }); - assert!(process_exists(pid), "child process was not running"); - - let removed = run_cli_json( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--force", - "--json", - ], - ); - assert_eq!(removed["result"]["type"], "worktree_removed"); - assert!(wait_for_pid_exit(pid, Duration::from_secs(3))); - assert!(!checkout.exists()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn worktree_open_existing_checkout_by_path_and_branch() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let repo = base.join("repo"); - let checkout = base.join("external-checkout"); - create_committed_repo(&repo); - let branch = "worktree/cli-open-existing"; - run_git( - &repo, - &[ - "worktree", - "add", - "--quiet", - "-b", - branch, - checkout.to_str().unwrap(), - "HEAD", - ], - ); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let opened = run_cli_json_in_dir( - &socket_path, - &[ - "worktree", - "open", - "--cwd", - "repo", - "--path", - "external-checkout", - "--json", - ], - &base, - ); - assert_eq!(opened["result"]["type"], "worktree_opened"); - assert_eq!(opened["result"]["already_open"], false); - assert_eq!(opened["result"]["worktree"]["branch"], branch); - assert_eq!( - opened["result"]["workspace"]["worktree"]["is_linked_worktree"], - true - ); - let child_workspace_id = opened["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let workspaces = run_cli_json(&socket_path, &["workspace", "list"]); - let workspace_list = workspaces["result"]["workspaces"].as_array().unwrap(); - let parent_workspace_id = workspace_list - .iter() - .find(|workspace| workspace["worktree"]["is_linked_worktree"].as_bool() == Some(false)) - .and_then(|workspace| workspace["workspace_id"].as_str()) - .unwrap() - .to_string(); - - let listed = run_cli_json( - &socket_path, - &[ - "worktree", - "list", - "--workspace", - &parent_workspace_id, - "--json", - ], - ); - let listed_entry = listed["result"]["worktrees"] - .as_array() - .unwrap() - .iter() - .find(|entry| entry["branch"].as_str() == Some(branch)) - .unwrap(); - assert_eq!( - listed_entry["open_workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - let reopened = run_cli_json( - &socket_path, - &[ - "worktree", - "open", - "--workspace", - &parent_workspace_id, - "--branch", - branch, - "--json", - ], - ); - assert_eq!(reopened["result"]["type"], "worktree_opened"); - assert_eq!(reopened["result"]["already_open"], true); - assert_eq!( - reopened["result"]["workspace"]["workspace_id"].as_str(), - Some(child_workspace_id.as_str()) - ); - - let removed = run_cli_json( - &socket_path, - &[ - "worktree", - "remove", - "--workspace", - &child_workspace_id, - "--force", - "--json", - ], - ); - assert_eq!(removed["result"]["type"], "worktree_removed"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn config_check_reports_invalid_config_without_server() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let config_dir = config_home.join(app_dir_name()); - fs::create_dir_all(&config_dir).unwrap(); - fs::write( - config_dir.join("config.toml"), - "[keys\nnew_workspace = \"g\"\n", - ) - .unwrap(); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert_eq!(checked.status.code(), Some(1)); - assert!( - checked.stderr.is_empty(), - "stderr: {}", - String::from_utf8_lossy(&checked.stderr) - ); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: issues found"), "{stdout}"); - assert!(stdout.contains("TOML parse error"), "{stdout}"); - assert!(stdout.contains("line 1"), "{stdout}"); - - fs::write( - config_dir.join("config.toml"), - "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\n", - ) - .unwrap(); - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert_eq!(checked.status.code(), Some(1)); - assert!(stdout.contains("sidebar_min_width (50)"), "{stdout}"); - assert!(stdout.contains("sidebar_max_width (30)"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_reports_unknown_keys_without_treating_comments_as_keys() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let config_dir = config_home.join(app_dir_name()); - fs::create_dir_all(&config_dir).unwrap(); - fs::write( - config_dir.join("config.toml"), - r#"made_up_toplevel = 1 -"made.up" = 2 -# commented_out_toplevel = true - -[ui] -mouse_capture = true -mouse_captur = false -# commented_out_ui_key = true - -[keys] -new_tabb = "ctrl+t" -"#, - ) - .unwrap(); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert_eq!(checked.status.code(), Some(1)); - assert!( - checked.stderr.is_empty(), - "stderr: {}", - String::from_utf8_lossy(&checked.stderr) - ); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: issues found"), "{stdout}"); - assert!( - stdout.contains("unknown config key made_up_toplevel; ignoring key"), - "{stdout}" - ); - assert!( - stdout.contains("unknown config key \"made.up\"; ignoring key"), - "{stdout}" - ); - assert!( - stdout.contains("unknown config key ui.mouse_captur; ignoring key"), - "{stdout}" - ); - assert!( - stdout.contains("unknown config key keys.new_tabb; ignoring key"), - "{stdout}" - ); - assert!(!stdout.contains("commented_out"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_reports_unreadable_config_path() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let config_path = config_home.join(app_dir_name()).join("config.toml"); - fs::create_dir_all(&config_path).unwrap(); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert_eq!(checked.status.code(), Some(1)); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: issues found"), "{stdout}"); - assert!(stdout.contains("config read error"), "{stdout}"); - assert!(stdout.contains("using defaults"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_reports_ok_when_config_is_missing() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]); - - assert!(checked.status.success()); - let stdout = String::from_utf8_lossy(&checked.stdout); - assert!(stdout.contains("config: ok"), "{stdout}"); - - cleanup_test_base(&base); -} - -#[test] -fn config_check_rejects_json_output() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - - let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check", "--json"]); - - assert_eq!(checked.status.code(), Some(2)); - assert!(checked.stdout.is_empty()); - assert_eq!( - String::from_utf8_lossy(&checked.stderr), - "usage: herdr config check\n" - ); - - cleanup_test_base(&base); -} - -#[test] -fn worktree_cli_rejects_local_argument_errors_before_socket_use() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("missing.sock"); - let cases: &[&[&str]] = &[ - &["worktree", "list", "--workspace", "1", "--cwd", "/tmp"], - &["worktree", "create", "--workspace", "1", "--cwd", "/tmp"], - &["worktree", "open", "--workspace", "1"], - &[ - "worktree", - "open", - "--workspace", - "1", - "--path", - "a", - "--branch", - "b", - ], - &[ - "worktree", - "open", - "--workspace", - "1", - "--cwd", - "/tmp", - "--branch", - "b", - ], - ]; - - for args in cases { - let output = run_cli(&socket_path, args); - assert_eq!( - output.status.code(), - Some(2), - "herdr {} should fail as local parse error; stdout={} stderr={}", - args.join(" "), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - cleanup_test_base(&base); -} - -#[test] -fn tab_management_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let workspace_id = created_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - let first_tab_id = created_json["result"]["workspace"]["active_tab_id"] - .as_str() - .unwrap() - .to_string(); - - let created_tab = run_cli( - &socket_path, - &["tab", "create", "--workspace", &workspace_id], - ); - assert!(created_tab.status.success()); - let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap(); - let second_tab_id = created_tab_json["result"]["tab"]["tab_id"] - .as_str() - .unwrap() - .to_string(); - assert_eq!(second_tab_id, format!("{workspace_id}:t2")); - - let listed_tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]); - assert!(listed_tabs.status.success()); - let listed_tabs_json: serde_json::Value = serde_json::from_slice(&listed_tabs.stdout).unwrap(); - assert_eq!( - listed_tabs_json["result"]["tabs"].as_array().unwrap().len(), - 2 - ); - - let renamed_tab = run_cli(&socket_path, &["tab", "rename", &second_tab_id, "logs"]); - assert!(renamed_tab.status.success()); - let renamed_tab_json: serde_json::Value = serde_json::from_slice(&renamed_tab.stdout).unwrap(); - assert_eq!(renamed_tab_json["result"]["tab"]["label"], "logs"); - - let focused_tab = run_cli(&socket_path, &["tab", "focus", &first_tab_id]); - assert!(focused_tab.status.success()); - let focused_tab_json: serde_json::Value = serde_json::from_slice(&focused_tab.stdout).unwrap(); - assert_eq!(focused_tab_json["result"]["tab"]["tab_id"], first_tab_id); - - let tab_get = run_cli(&socket_path, &["tab", "get", &second_tab_id]); - assert!(tab_get.status.success()); - let tab_get_json: serde_json::Value = serde_json::from_slice(&tab_get.stdout).unwrap(); - assert_eq!(tab_get_json["result"]["tab"]["tab_id"], second_tab_id); - - let closed_tab = run_cli(&socket_path, &["tab", "close", &second_tab_id]); - assert!(closed_tab.status.success()); - let closed_tab_json: serde_json::Value = serde_json::from_slice(&closed_tab.stdout).unwrap(); - assert_eq!(closed_tab_json["result"]["type"], "ok"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_start_command_works() { - use std::os::unix::fs::PermissionsExt; - - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let bin = base.join("bin"); - let captured_args = base.join("pi-args"); - fs::create_dir_all(&bin).unwrap(); - let fake_pi = bin.join("pi"); - fs::write( - &fake_pi, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nexport HERDR_AGENT=pi\n'{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\nwhile IFS= read -r prompt; do\n [ \"$prompt\" = \"do not transition\" ] && continue\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state working >/dev/null\n '{}' pane report-agent \"$HERDR_PANE_ID\" --source custom:fake-pi --agent pi --state idle >/dev/null\ndone\n", - captured_args.display(), - env!("CARGO_BIN_EXE_herdr"), - env!("CARGO_BIN_EXE_herdr"), - env!("CARGO_BIN_EXE_herdr"), - ), - ) - .unwrap(); - fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); - - let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let before = run_cli_json(&socket_path, &["pane", "list"]); - let before_topology = pane_topology_snapshot(&before); - - let missing = run_cli( - &socket_path, - &[ - "agent", "start", "missing", "--kind", "pi", "--pane", "w999:p1", - ], - ); - assert_eq!(missing.status.code(), Some(1)); - let missing: serde_json::Value = serde_json::from_slice(&missing.stderr).unwrap(); - assert_eq!(missing["error"]["code"], "agent_pane_not_found"); - assert_eq!( - pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])), - before_topology - ); - - for unsafe_arg in ["tab\tcompletion", "escape\x1b[201~"] { - let rejected = run_cli( - &socket_path, - &[ - "agent", - "start", - "invalid-argument", - "--kind", - "pi", - "--pane", - &pane_id, - "--", - unsafe_arg, - ], - ); - assert_eq!(rejected.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap(); - assert_eq!(error["error"]["code"], "invalid_agent_argument"); - } - - for invalid_timeout in ["3000", "300001"] { - let rejected = run_cli( - &socket_path, - &[ - "agent", - "start", - "invalid-timeout", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - invalid_timeout, - ], - ); - assert_eq!(rejected.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&rejected.stderr).unwrap(); - assert_eq!(error["error"]["code"], "invalid_agent_timeout"); - } - - let started = run_cli_json( - &socket_path, - &[ - "agent", - "start", - "main", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "8000", - "--", - "--name", - "scratch", - "--no-session", - ], - ); - assert_eq!(started["result"]["type"], "agent_started"); - assert_eq!(started["result"]["agent"]["name"], "main"); - assert_eq!(started["result"]["agent"]["agent"], "pi"); - assert_eq!(started["result"]["agent"]["pane_id"], pane_id); - assert_eq!(started["result"]["argv"][0], "pi"); - assert_eq!(started["result"]["argv"][1], "--name"); - assert_eq!(started["result"]["argv"][2], "scratch"); - assert_eq!(started["result"]["argv"][3], "--no-session"); - assert_eq!( - fs::read_to_string(&captured_args).unwrap(), - "--name\nscratch\n--no-session\n" - ); - - let literal_flag_prompt = run_cli(&socket_path, &["agent", "prompt", "main", "--wait"]); - assert!( - literal_flag_prompt.status.success(), - "flag-shaped prompt was not treated literally: {}", - String::from_utf8_lossy(&literal_flag_prompt.stderr) - ); - - let after = run_cli_json(&socket_path, &["pane", "list"]); - assert_eq!(pane_topology_snapshot(&after), before_topology); - - let stale_idle = run_cli( - &socket_path, - &[ - "agent", - "prompt", - "main", - "do not transition", - "--wait", - "--timeout", - "200", - ], - ); - assert_eq!(stale_idle.status.code(), Some(1)); - let stale_idle: serde_json::Value = serde_json::from_slice(&stale_idle.stderr).unwrap(); - assert_eq!(stale_idle["error"]["code"], "timeout"); - - let prompted = run_cli( - &socket_path, - &[ - "agent", - "prompt", - "main", - "Review this diff", - "--wait", - "--timeout", - "2000", - ], - ); - assert!( - prompted.status.success(), - "prompt failed: {}", - String::from_utf8_lossy(&prompted.stderr) - ); - let prompted: serde_json::Value = serde_json::from_slice(&prompted.stdout).unwrap(); - assert_eq!(prompted["result"]["type"], "agent_prompted"); - - let duplicate = run_cli( - &socket_path, - &["agent", "start", "main", "--kind", "pi", "--pane", &pane_id], - ); - assert!(!duplicate.status.success()); - let duplicate_json: serde_json::Value = serde_json::from_slice(&duplicate.stderr).unwrap(); - assert_eq!(duplicate_json["error"]["code"], "agent_name_taken"); - - let busy = run_cli( - &socket_path, - &[ - "agent", "start", "second", "--kind", "pi", "--pane", &pane_id, - ], - ); - assert!(!busy.status.success()); - let busy_json: serde_json::Value = serde_json::from_slice(&busy.stderr).unwrap(); - assert_eq!(busy_json["error"]["code"], "agent_pane_busy"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_start_rejects_a_shell_replaced_by_a_foreground_program() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let topology = pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])); - assert!( - run_cli(&socket_path, &["pane", "run", &pane_id, "exec sleep 5"]) - .status - .success() - ); - thread::sleep(Duration::from_millis(150)); - - let started = run_cli( - &socket_path, - &[ - "agent", - "start", - "worker", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "1000", - ], - ); - assert_eq!(started.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_pane_busy"); - assert_eq!( - pane_topology_snapshot(&run_cli_json(&socket_path, &["pane", "list"])), - topology - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_start_timeout_releases_the_name_for_reuse() { - use std::os::unix::fs::PermissionsExt; - - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let bin = base.join("bin"); - fs::create_dir_all(&bin).unwrap(); - let fake_pi = bin.join("pi"); - fs::write( - &fake_pi, - "#!/bin/sh\nunset HERDR_AGENT\nexec /bin/sleep 20\n", - ) - .unwrap(); - fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); - - let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let started = run_cli( - &socket_path, - &[ - "agent", - "start", - "worker", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "3100", - ], - ); - assert_eq!(started.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); - assert_eq!(error["error"]["code"], "timeout"); - - let reused = run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]); - assert!( - reused.status.success(), - "name was not released: {}", - String::from_utf8_lossy(&reused.stderr) - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_start_reports_detected_kind_mismatch_before_released_name() { - use std::os::unix::fs::PermissionsExt; - - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let bin = base.join("bin"); - fs::create_dir_all(&bin).unwrap(); - let fake_pi = bin.join("pi"); - fs::write( - &fake_pi, - "#!/bin/sh\nHERDR_AGENT=codex exec /bin/sleep 10\n", - ) - .unwrap(); - fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); - - let herdr = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let started = run_cli( - &socket_path, - &[ - "agent", - "start", - "worker", - "--kind", - "pi", - "--pane", - &pane_id, - "--timeout", - "5000", - ], - ); - assert_eq!(started.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_kind_mismatch"); - - let reused = run_cli(&socket_path, &["agent", "rename", &pane_id, "worker"]); - assert!(reused.status.success()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let root_pane_id = created_json["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let terminal_id = created_json["result"]["root_pane"]["terminal_id"] - .as_str() - .unwrap() - .to_string(); - - let renamed = run_cli(&socket_path, &["agent", "rename", &root_pane_id, "worker"]); - assert!(renamed.status.success()); - - let listed = run_cli_json(&socket_path, &["agent", "list"]); - assert_eq!(listed["result"]["type"], "agent_list"); - assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id); - assert_eq!(listed["result"]["agents"][0]["name"], "worker"); - - let fetched = run_cli_json(&socket_path, &["agent", "get", "worker"]); - assert_eq!(fetched["result"]["agent"]["pane_id"], root_pane_id); - - let reported = run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:test", - "--agent", - "pi", - "--state", - "idle", - ], - ); - assert!(reported.status.success()); - let waited = run_cli_json( - &socket_path, - &["agent", "wait", "worker", "--timeout", "100"], - ); - assert_eq!(waited["result"]["agent"]["pane_id"], root_pane_id); - - // A stale semantic report must not allow prompt text into the resumed shell. - let prompted = run_cli( - &socket_path, - &["agent", "prompt", "worker", "echo prompt-must-not-run"], - ); - assert_eq!(prompted.status.code(), Some(1)); - let prompted_json: serde_json::Value = serde_json::from_slice(&prompted.stderr).unwrap(); - assert_eq!(prompted_json["error"]["code"], "agent_not_ready"); - - let working = run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "working", - ], - ); - assert!(working.status.success()); - let blocked_socket = socket_path.clone(); - let blocked_pane = root_pane_id.clone(); - let blocked_transition = thread::spawn(move || { - thread::sleep(Duration::from_millis(100)); - let blocked = run_cli( - &blocked_socket, - &[ - "pane", - "report-agent", - &blocked_pane, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "blocked", - ], - ); - assert!(blocked.status.success()); - }); - let waited = run_cli_json( - &socket_path, - &["agent", "wait", "worker", "--timeout", "2000"], - ); - blocked_transition.join().unwrap(); - assert_eq!(waited["result"]["agent"]["agent_status"], "blocked"); - let immediate_blocked = - run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]); - assert_eq!( - immediate_blocked["result"]["agent"]["agent_status"], - "blocked" - ); - - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - let idle_socket = socket_path.clone(); - let idle_pane = root_pane_id.clone(); - let idle_transition = thread::spawn(move || { - thread::sleep(Duration::from_millis(100)); - assert!(run_cli( - &idle_socket, - &[ - "pane", - "report-agent", - &idle_pane, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - }); - let idle_wait = run_cli_json( - &socket_path, - &["agent", "wait", "worker", "--timeout", "2000"], - ); - idle_transition.join().unwrap(); - assert!(matches!( - idle_wait["result"]["agent"]["agent_status"].as_str(), - Some("idle" | "done") - )); - - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - let timed_out = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "100"], - ); - assert_eq!(timed_out.status.code(), Some(1)); - let timeout: serde_json::Value = serde_json::from_slice(&timed_out.stderr).unwrap(); - assert_eq!(timeout["error"]["code"], "timeout"); - - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &root_pane_id, - "--source", - "custom:wait", - "--agent", - "pi", - "--state", - "unknown", - ], - ) - .status - .success()); - let unknown = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "1000"], - ); - assert_eq!(unknown.status.code(), Some(1)); - let unknown: serde_json::Value = serde_json::from_slice(&unknown.stderr).unwrap(); - assert_eq!(unknown["error"]["code"], "agent_not_running"); - - let read = run_cli_json( - &socket_path, - &["agent", "read", &terminal_id, "--source", "visible"], - ); - assert_eq!(read["result"]["type"], "pane_read"); - - let sent = run_cli( - &socket_path, - &["agent", "send", "worker", "echo cli-agent-ok\n"], - ); - assert!(sent.status.success()); - - let agent_renamed = run_cli_json(&socket_path, &["agent", "rename", "worker", "reviewer"]); - assert_eq!(agent_renamed["result"]["agent"]["name"], "reviewer"); - - let focused = run_cli_json(&socket_path, &["agent", "focus", "reviewer"]); - assert_eq!(focused["result"]["agent"]["focused"], true); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_wait_returns_immediately_for_unseen_done_agent() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let first = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap(); - let second_tab = run_cli_json( - &socket_path, - &["tab", "create", "--workspace", workspace_id], - ); - let second_tab_id = second_tab["result"]["tab"]["tab_id"].as_str().unwrap(); - assert_ne!(second_tab_id, "w1:t1"); - assert!(run_cli(&socket_path, &["tab", "focus", second_tab_id]) - .status - .success()); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:done", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &first, "worker"]) - .status - .success() - ); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:done", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - - let waited = run_cli_json(&socket_path, &["agent", "wait", "worker", "--timeout", "1"]); - assert_eq!(waited["result"]["agent"]["agent_status"], "done"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_wait_pins_the_original_terminal_when_name_is_reused() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let first = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let split = run_cli_json( - &socket_path, - &["pane", "split", &first, "--direction", "right"], - ); - let second = split["result"]["pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:race", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &first, "worker"]) - .status - .success() - ); - - let wait_socket = socket_path.clone(); - let waiter = thread::spawn(move || { - run_cli( - &wait_socket, - &["agent", "wait", "worker", "--timeout", "2000"], - ) - }); - thread::sleep(Duration::from_millis(250)); - assert!( - run_cli(&socket_path, &["agent", "rename", "worker", "--clear"]) - .status - .success() - ); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &second, - "--source", - "custom:race", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &second, "worker"]) - .status - .success() - ); - - let waited = waiter.join().unwrap(); - assert_eq!(waited.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_name_not_found"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn agent_wait_ignores_other_panes_and_errors_when_its_pane_closes() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let first = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - let split = run_cli_json( - &socket_path, - &["pane", "split", &first, "--direction", "right"], - ); - let second = split["result"]["pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &first, - "--source", - "custom:close", - "--agent", - "pi", - "--state", - "working", - ], - ) - .status - .success()); - assert!( - run_cli(&socket_path, &["agent", "rename", &first, "worker"]) - .status - .success() - ); - - let wait_socket = socket_path.clone(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - thread::spawn(move || { - let _ = done_tx.send(run_cli( - &wait_socket, - &["agent", "wait", "worker", "--timeout", "3000"], - )); - }); - thread::sleep(Duration::from_millis(150)); - assert!(run_cli( - &socket_path, - &[ - "pane", - "report-agent", - &second, - "--source", - "custom:close", - "--agent", - "pi", - "--state", - "idle", - ], - ) - .status - .success()); - thread::sleep(Duration::from_millis(150)); - assert!(matches!( - done_rx.try_recv(), - Err(std::sync::mpsc::TryRecvError::Empty) - )); - - assert!(run_cli(&socket_path, &["pane", "close", &first]) - .status - .success()); - let waited = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert_eq!(waited.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert!(matches!( - error["error"]["code"].as_str(), - Some("agent_not_found" | "pane_not_found") - )); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let workspace_id = created_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let created_tab = run_cli( - &socket_path, - &["tab", "create", "--workspace", &workspace_id], - ); - assert!(created_tab.status.success()); - let created_tab_json: serde_json::Value = serde_json::from_slice(&created_tab.stdout).unwrap(); - let second_root_pane_id = created_tab_json["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let closed = run_cli(&socket_path, &["pane", "close", &second_root_pane_id]); - assert!(closed.status.success()); - let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); - assert_eq!(closed_json["result"]["type"], "ok"); - - let workspaces = run_cli(&socket_path, &["workspace", "list"]); - assert!(workspaces.status.success()); - let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); - assert_eq!( - workspaces_json["result"]["workspaces"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!( - workspaces_json["result"]["workspaces"][0]["workspace_id"], - workspace_id - ); - - let tabs = run_cli(&socket_path, &["tab", "list", "--workspace", &workspace_id]); - assert!(tabs.status.success()); - let tabs_json: serde_json::Value = serde_json::from_slice(&tabs.stdout).unwrap(); - assert_eq!(tabs_json["result"]["tabs"].as_array().unwrap().len(), 1); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_close_removes_the_workspace_when_it_closes_the_last_pane() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); - let root_pane_id = created_json["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let closed = run_cli(&socket_path, &["pane", "close", &root_pane_id]); - assert!(closed.status.success()); - let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); - assert_eq!(closed_json["result"]["type"], "ok"); - - let workspaces = run_cli(&socket_path, &["workspace", "list"]); - assert!(workspaces.status.success()); - let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap(); - assert!(workspaces_json["result"]["workspaces"] - .as_array() - .unwrap() - .is_empty()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_run_read_and_wait_commands_work() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - let create = run_cli( - &socket_path, - &[ - "pane", - "run", - "1-1", - "echo alpha && echo beta && printf 'ready\\n'", - ], - ); - assert!(create.status.success()); - - let started = Instant::now(); - let waited = run_cli( - &socket_path, - &[ - "wait", - "output", - "1-1", - "--match", - "ready", - "--source", - "recent", - "--lines", - "40", - "--timeout", - "5000", - ], - ); - let elapsed = started.elapsed(); - assert!( - waited.status.success(), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - assert!( - elapsed < Duration::from_millis(500), - "already-matching wait took {elapsed:?}" - ); - let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); - assert_eq!(waited_json["result"]["type"], "output_matched"); - - let read = run_cli( - &socket_path, - &["pane", "read", "1-1", "--source", "recent", "--lines", "40"], - ); - assert!(read.status.success()); - let text = String::from_utf8(read.stdout).unwrap(); - assert!(text.contains("alpha")); - assert!(text.contains("ready")); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn wait_output_matches_recent_unwrapped_text() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - - let token = "WRAP_WAIT_TEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; - let script = base.join("emit-long-token.sh"); - std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{token}'\n")).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script, perms).unwrap(); - } - - let run = run_cli( - &socket_path, - &["pane", "run", "1-1", &format!("sh {}", script.display())], - ); - assert!(run.status.success()); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "output", - "1-1", - "--match", - token, - "--source", - "recent", - "--lines", - "80", - "--timeout", - "5000", - ], - ); - assert!( - waited.status.success(), - "stderr: {} stdout: {}", - String::from_utf8_lossy(&waited.stderr), - String::from_utf8_lossy(&waited.stdout) - ); - - let read = run_cli( - &socket_path, - &[ - "pane", - "read", - "1-1", - "--source", - "recent-unwrapped", - "--lines", - "80", - ], - ); - assert!(read.status.success()); - let text = String::from_utf8(read.stdout).unwrap(); - assert!(text.contains(token)); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn closing_pane_terminates_processes_inside_it() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - - let split = run_cli( - &socket_path, - &["pane", "split", "1-1", "--direction", "right"], - ); - assert!(split.status.success()); - let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap(); - let pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap(); - - let pid_file = base.join("pane-close.pid"); - let command = format!( - "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", - pid_file.display() - ); - let ran = run_cli(&socket_path, &["pane", "run", pane_id, &command]); - assert!( - ran.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ran.stderr) - ); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline && !pid_file.exists() { - thread::sleep(Duration::from_millis(25)); - } - assert!(pid_file.exists(), "pid file was not created"); - - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| { - panic!("failed to read pane child pid: {err}"); - }); - assert!(process_exists(pid), "child process was not running"); - - let closed = run_cli(&socket_path, &["pane", "close", pane_id]); - assert!( - closed.status.success(), - "stderr: {}", - String::from_utf8_lossy(&closed.stderr) - ); - assert!( - wait_for_pid_exit(pid, Duration::from_secs(3)), - "process {pid} survived pane close" - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn closing_workspace_terminates_processes_inside_it() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = run_cli( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - assert!(created.status.success()); - - let pid_file = base.join("workspace-close.pid"); - let command = format!( - "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'", - pid_file.display() - ); - let ran = run_cli(&socket_path, &["pane", "run", "1-1", &command]); - assert!( - ran.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ran.stderr) - ); - - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline && !pid_file.exists() { - thread::sleep(Duration::from_millis(25)); - } - assert!(pid_file.exists(), "pid file was not created"); - - let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| { - panic!("failed to read pane child pid: {err}"); - }); - assert!(process_exists(pid), "child process was not running"); - - let closed = run_cli(&socket_path, &["workspace", "close", "1"]); - assert!( - closed.status.success(), - "stderr: {}", - String::from_utf8_lossy(&closed.stderr) - ); - assert!( - wait_for_pid_exit(pid, Duration::from_secs(3)), - "process {pid} survived workspace close" - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn workspace_ids_and_public_pane_ids_are_stable() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let ws1_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", base.to_str().unwrap()], - ); - let ws1_id = ws1_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let split_12_json = run_cli_json( - &socket_path, - &["pane", "split", "1-1", "--direction", "right", "--no-focus"], - ); - assert_eq!( - split_12_json["result"]["pane"]["pane_id"], - format!("{ws1_id}:p2") - ); - - let split_13_json = run_cli_json( - &socket_path, - &["pane", "split", "1-1", "--direction", "down", "--no-focus"], - ); - assert_eq!( - split_13_json["result"]["pane"]["pane_id"], - format!("{ws1_id}:p3") - ); - - let ws2_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", "/tmp", "--no-focus"], - ); - let ws2_id = ws2_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert_ne!(ws2_id, ws1_id); - - let ws2_focus = run_cli(&socket_path, &["workspace", "focus", &ws2_id]); - assert!( - ws2_focus.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ws2_focus.stderr) - ); - - let ws2_split_json = run_cli_json( - &socket_path, - &["pane", "split", "2-1", "--direction", "right", "--no-focus"], - ); - assert_eq!( - ws2_split_json["result"]["pane"]["pane_id"], - format!("{ws2_id}:p2") - ); - - let ws3_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", "/", "--no-focus"], - ); - let ws3_id = ws3_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert_ne!(ws3_id, ws1_id); - assert_ne!(ws3_id, ws2_id); - - let close_ws2 = run_cli(&socket_path, &["workspace", "close", &ws2_id]); - assert!( - close_ws2.status.success(), - "stderr: {}", - String::from_utf8_lossy(&close_ws2.stderr) - ); - - let workspaces_json = run_cli_json(&socket_path, &["workspace", "list"]); - let ids: Vec<String> = workspaces_json["result"]["workspaces"] - .as_array() - .unwrap() - .iter() - .map(|ws| ws["workspace_id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(ids, vec![ws1_id.clone(), ws3_id.clone()]); - - let new_ws_json = run_cli_json( - &socket_path, - &["workspace", "create", "--cwd", "/var/tmp", "--no-focus"], - ); - let new_ws_id = new_ws_json["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - assert_ne!(new_ws_id, ws1_id); - assert_ne!(new_ws_id, ws2_id); - assert_ne!(new_ws_id, ws3_id); - - let ws3_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws3_id]); - assert_eq!( - ws3_panes_json["result"]["panes"][0]["pane_id"], - format!("{ws3_id}:p1") - ); - - let close_middle = run_cli(&socket_path, &["pane", "close", &format!("{ws1_id}-2")]); - assert!( - close_middle.status.success(), - "stderr: {}", - String::from_utf8_lossy(&close_middle.stderr) - ); - - let ws1_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws1_id]); - let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"] - .as_array() - .unwrap() - .iter() - .map(|pane| pane["pane_id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!( - pane_ids, - vec![format!("{ws1_id}:p1"), format!("{ws1_id}:p3")] - ); - - let closed_lookup = run_cli(&socket_path, &["pane", "get", &format!("{ws1_id}:p2")]); - assert!( - !closed_lookup.status.success(), - "closed pane id should not retarget: {}", - String::from_utf8_lossy(&closed_lookup.stdout) - ); - - let split_14_json = run_cli_json( - &socket_path, - &[ - "pane", - "split", - &format!("{ws1_id}:p1"), - "--direction", - "right", - "--no-focus", - ], - ); - assert_eq!( - split_14_json["result"]["pane"]["pane_id"], - format!("{ws1_id}:p4") - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn pane_shell_gets_herdr_socket_and_pane_env() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_env_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - let pane_id = created["result"]["root_pane"]["pane_id"] - .as_str() - .unwrap() - .to_string(); - - let env_capture = base.join("pane-env.txt"); - let ran = run_cli( - &socket_path, - &[ - "pane", - "run", - "1-1", - &format!( - "printf '%s\\n%s\\n' \"$HERDR_SOCKET_PATH\" \"$HERDR_PANE_ID\" > {}", - env_capture.display() - ), - ], - ); - assert!(ran.status.success()); - - let deadline = Instant::now() + Duration::from_secs(3); - let mut text = String::new(); - while Instant::now() < deadline { - if env_capture.exists() { - text = fs::read_to_string(&env_capture).unwrap(); - if text.contains(&socket_path.display().to_string()) && text.contains(&pane_id) { - break; - } - } - thread::sleep(Duration::from_millis(25)); - } - assert!(env_capture.exists(), "env capture file was not created"); - assert!( - text.contains(&socket_path.display().to_string()), - "env file was: {text:?}" - ); - assert!(text.contains(&pane_id), "env file was: {text:?}"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn wait_agent_status_exits_when_idle_status_matches() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let bin_dir = base.join("bin"); - - fs::create_dir_all(&bin_dir).unwrap(); - let fake_pi = bin_dir.join("pi"); - fs::write( - &fake_pi, - "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n", - ) - .unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&fake_pi).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&fake_pi, perms).unwrap(); - } - - let inherited_path = std::env::var("PATH").unwrap_or_default(); - let path_override = format!("{}:{}", bin_dir.display(), inherited_path); - let herdr = spawn_herdr_with_path( - &config_home, - &runtime_dir, - &socket_path, - Some(Path::new(&path_override)), - ); - - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_2","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - assert!(created["result"]["workspace"]["workspace_id"].is_string()); - - let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]); - assert!(start_pi.status.success()); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "idle", - "--timeout", - "10000", - ], - ); - assert!( - waited.status.success(), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); - assert_eq!(waited_json["event"], "pane.agent_status_changed"); - assert_eq!(waited_json["data"]["agent_status"], "idle"); - assert_eq!(waited_json["data"]["agent"], "pi"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn plugin_link_list_unlink_cli_smoke_test() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let plugin_dir = base.join("plugins").join("layout"); - fs::create_dir_all(&plugin_dir).unwrap(); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.layout" -name = "Layout" -version = "0.1.0" -min_herdr_version = "0.6.10" -description = "Apply a preferred Herdr layout" - -[[actions]] -id = "apply" -title = "Apply layout" -contexts = ["workspace"] -command = ["sh", "-c", "echo layout"] - -[[events]] -on = "worktree.created" -command = ["sh", "-c", "echo worktree"] - -[[panes]] -id = "board" -title = "Board" -placement = "tab" -command = ["sh", "-c", "sleep 5"] -"#, - ) - .unwrap(); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - let workspace = run_cli_json( - &socket_path, - &[ - "workspace", - "create", - "--cwd", - base.to_str().unwrap(), - "--focus", - ], - ); - assert_eq!(workspace["result"]["type"], "workspace_created"); - - let linked = run_cli_json_in_dir(&socket_path, &["plugin", "link", "plugins/layout"], &base); - assert_eq!(linked["result"]["type"], "plugin_linked"); - assert_eq!(linked["result"]["plugin"]["plugin_id"], "example.layout"); - assert_eq!(linked["result"]["plugin"]["actions"][0]["id"], "apply"); - assert_eq!( - linked["result"]["plugin"]["events"][0]["on"], - "worktree.created" - ); - assert_eq!(linked["result"]["plugin"]["panes"][0]["id"], "board"); - - let listed_human = run_cli(&socket_path, &["plugin", "list"]); - assert!(listed_human.status.success()); - assert!(String::from_utf8_lossy(&listed_human.stdout).contains("example.layout")); - - let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); - assert_eq!(listed["result"]["type"], "plugin_list"); - assert_eq!( - listed["result"]["plugins"][0]["plugin_id"], - "example.layout" - ); - - let invoked = run_cli_json( - &socket_path, - &[ - "plugin", - "action", - "invoke", - "apply", - "--plugin", - "example.layout", - ], - ); - assert_eq!(invoked["result"]["type"], "plugin_action_invoked"); - assert_eq!(invoked["result"]["action"]["action_id"], "apply"); - - let logs = run_cli_json( - &socket_path, - &[ - "plugin", - "log", - "list", - "--plugin", - "example.layout", - "--limit", - "5", - ], - ); - assert_eq!(logs["result"]["type"], "plugin_log_list"); - assert!(!logs["result"]["logs"].as_array().unwrap().is_empty()); - - let pane = run_cli_json( - &socket_path, - &[ - "plugin", - "pane", - "open", - "--plugin", - "example.layout", - "--entrypoint", - "board", - "--env", - "HERDR_ROLE=board", - "--no-focus", - ], - ); - assert_eq!(pane["result"]["type"], "plugin_pane_opened"); - assert_eq!(pane["result"]["plugin_pane"]["entrypoint"], "board"); - - let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]); - assert_eq!(missing_plugin_value.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&missing_plugin_value.stderr) - .contains("missing value for --plugin")); - - let invalid_limit = run_cli( - &socket_path, - &["plugin", "log", "list", "--limit", "not-a-number"], - ); - assert_eq!(invalid_limit.status.code(), Some(2)); - assert!(String::from_utf8_lossy(&invalid_limit.stderr).contains("invalid --limit value")); - - let unlinked = run_cli_json(&socket_path, &["plugin", "unlink", "example.layout"]); - assert_eq!(unlinked["result"]["type"], "plugin_unlinked"); - assert_eq!(unlinked["result"]["removed"], true); - - let listed = run_cli_json(&socket_path, &["plugin", "list", "--json"]); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn plugin_install_list_uninstall_offline_cli_smoke_test() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "-c", "echo built > built.txt; if [ -n \"$HERDR_SESSION\" ]; then echo \"$HERDR_SESSION\" > leaked-session.txt; fi"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo bootstrap"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[ - ("GIT_CONFIG_GLOBAL", &git_config), - ("HERDR_SESSION", Path::new("leaked-session")), - ], - ); - assert!( - install.status.success(), - "install failed\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&install.stdout), - String::from_utf8_lossy(&install.stderr) - ); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - let plugin = &listed["result"]["plugins"][0]; - assert_eq!(plugin["plugin_id"], "example.worktree-bootstrap"); - assert_eq!(plugin["source"]["kind"], "github"); - assert_eq!(plugin["source"]["owner"], "ogulcancelik"); - assert_eq!(plugin["source"]["repo"], "herdr-plugin-examples"); - assert_eq!(plugin["source"]["subdir"], "worktree-bootstrap"); - 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"); - assert!( - managed_path - .join("worktree-bootstrap") - .join("built.txt") - .exists(), - "build artifact should be preserved in managed checkout" - ); - assert!( - !managed_path - .join("worktree-bootstrap") - .join("leaked-session.txt") - .exists(), - "build command should not inherit HERDR_SESSION" - ); - - let uninstall = run_named_cli( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "uninstall", - "example.worktree-bootstrap", - ], - ); - assert!( - uninstall.status.success(), - "uninstall failed\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&uninstall.stdout), - String::from_utf8_lossy(&uninstall.stderr) - ); - assert!( - !managed_path.exists(), - "managed checkout should be deleted on uninstall" - ); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_build_failure_does_not_register_or_create_checkout() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("build-fail"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.build-fail" -name = "Build Fail" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "-c", "echo before-fail && echo failed-build >&2 && exit 7"] - -[[actions]] -id = "run" -title = "Run" -command = ["sh", "-c", "echo should-not-install"] -"#, - ) - .unwrap(); - run_git(&source_repo, &["add", "build-fail/herdr-plugin.toml"]); - run_git( - &source_repo, - &["commit", "--quiet", "-m", "add failing plugin"], - ); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/build-fail", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - ); - assert!( - !install.status.success(), - "install should fail when build command fails" - ); - assert_eq!(install.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&install.stderr); - assert!(stderr.contains("error: plugin build failed"), "{stderr}"); - assert!(stderr.contains(" plugin: example.build-fail"), "{stderr}"); - assert!(stderr.contains(" build: 1/1"), "{stderr}"); - assert!(stderr.contains(" cwd: "), "{stderr}"); - assert!( - stderr.contains(" command: sh -c echo before-fail && echo failed-build >&2 && exit 7"), - "{stderr}" - ); - assert!(stderr.contains(" status: exit status: 7"), "{stderr}"); - assert!(stderr.contains("stdout:\nbefore-fail"), "{stderr}"); - assert!(stderr.contains("stderr:\nfailed-build"), "{stderr}"); - assert!(stderr.contains("Plugin was not installed."), "{stderr}"); - assert!(!stderr.contains("Error: Custom"), "{stderr}"); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - assert!( - path_missing_or_empty(&managed_github_plugin_dir(&config_home)), - "failed build should not leave managed checkouts" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_build_spawn_failure_prints_clean_error() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("missing-tool"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.missing-tool" -name = "Missing Tool" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["definitely-missing-herdr-build-tool-xyz"] - -[[actions]] -id = "run" -title = "Run" -command = ["sh", "-c", "echo should-not-install"] -"#, - ) - .unwrap(); - run_git(&source_repo, &["add", "missing-tool/herdr-plugin.toml"]); - run_git( - &source_repo, - &["commit", "--quiet", "-m", "add missing tool plugin"], - ); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/missing-tool", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - ); - assert!( - !install.status.success(), - "install should fail when build command cannot start" - ); - assert_eq!(install.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&install.stderr); - assert!(stderr.contains("error: plugin build failed"), "{stderr}"); - assert!( - stderr.contains(" plugin: example.missing-tool"), - "{stderr}" - ); - assert!(stderr.contains(" build: 1/1"), "{stderr}"); - assert!( - stderr.contains(" command: definitely-missing-herdr-build-tool-xyz"), - "{stderr}" - ); - assert!(stderr.contains(" error: failed to start:"), "{stderr}"); - assert!(stderr.contains("Plugin was not installed."), "{stderr}"); - assert!(!stderr.contains("Error: Custom"), "{stderr}"); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_rejects_manifest_changed_by_build() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("manifest-mutator"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.manifest-mutator" -name = "Manifest Mutator" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "mutate.sh"] - -[[actions]] -id = "run" -title = "Run reviewed command" -command = ["sh", "-c", "echo reviewed"] -"#, - ) - .unwrap(); - fs::write( - plugin_dir.join("mutate.sh"), - r#"cat > herdr-plugin.toml <<'EOF' -id = "example.manifest-mutator" -name = "Manifest Mutator" -version = "0.1.0" -min_herdr_version = "0.0.1" -platforms = ["linux", "macos", "windows"] - -[[build]] -command = ["sh", "mutate.sh"] - -[[actions]] -id = "run" -title = "Run reviewed command" -command = ["sh", "-c", "echo reviewed"] -EOF -"#, - ) - .unwrap(); - run_git(&source_repo, &["add", "manifest-mutator"]); - run_git( - &source_repo, - &["commit", "--quiet", "-m", "add mutating plugin"], - ); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let install = run_named_cli_with_env( - &config_home, - &runtime_dir, - &[ - "--session", - "plugins", - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/manifest-mutator", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - ); - assert!( - !install.status.success(), - "install should fail when build changes reviewed manifest" - ); - let stderr = String::from_utf8_lossy(&install.stderr); - assert!( - stderr.contains("plugin build changed herdr-plugin.toml after install preview"), - "{stderr}" - ); - - let listed = run_named_cli_json( - &config_home, - &runtime_dir, - &["--session", "plugins", "plugin", "list", "--json"], - ); - assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); - - assert!( - path_missing_or_empty(&managed_github_plugin_dir(&config_home)), - "manifest mutation should not leave managed checkouts" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_restores_previous_checkout_when_registration_fails() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.2.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo new"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - fs::create_dir_all(&managed_checkout).unwrap(); - fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap(); - - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, first_line) = accept_fake_cli_operation(&listener); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - assert_eq!(first_request["method"], "plugin.list"); - writeln!( - first, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_list", - "plugins": [{ - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": { - "kind": "github", - "owner": "ogulcancelik", - "repo": "herdr-plugin-examples", - "subdir": "worktree-bootstrap", - "resolved_commit": "old", - "managed_path": managed_checkout_for_server.display().to_string(), - "installed_unix_ms": 1 - } - }] - } - }) - ) - .unwrap(); - first.flush().unwrap(); - - let (mut second, second_line) = accept_fake_cli_operation(&listener); - let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); - assert_eq!(second_request["method"], "plugin.link"); - second - .write_all( - br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#, - ) - .unwrap(); - second.write_all(b"\n").unwrap(); - second.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when plugin.link fails" - ); - server.join().unwrap(); - assert!( - managed_checkout.join("old-marker").exists(), - "old checkout should be restored after registration failure" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_rejects_server_that_drops_source_metadata() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo install"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, first_line) = accept_fake_cli_operation(&listener); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - assert_eq!(first_request["method"], "plugin.list"); - first - .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) - .unwrap(); - first.write_all(b"\n").unwrap(); - first.flush().unwrap(); - - let (mut second, second_line) = accept_fake_cli_operation(&listener); - let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); - assert_eq!(second_request["method"], "plugin.link"); - writeln!( - second, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_linked", - "plugin": { - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": {"kind": "local"} - } - } - }) - ) - .unwrap(); - second.flush().unwrap(); - - let (mut third, third_line) = accept_fake_cli_operation(&listener); - let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); - assert_eq!(third_request["method"], "plugin.unlink"); - assert_eq!( - third_request["params"]["plugin_id"], - "example.worktree-bootstrap" - ); - third - .write_all( - br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#, - ) - .unwrap(); - third.write_all(b"\n").unwrap(); - third.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when server drops GitHub source metadata" - ); - server.join().unwrap(); - assert!( - !managed_checkout.exists(), - "new checkout should be removed after incompatible plugin.link response" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo install"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, _first_line) = accept_fake_cli_operation(&listener); - first - .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) - .unwrap(); - first.write_all(b"\n").unwrap(); - first.flush().unwrap(); - - let (mut second, _second_line) = accept_fake_cli_operation(&listener); - writeln!( - second, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_linked", - "plugin": { - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": {"kind": "local"} - } - } - }) - ) - .unwrap(); - second.flush().unwrap(); - - let (mut third, third_line) = accept_fake_cli_operation(&listener); - let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); - assert_eq!(third_request["method"], "plugin.unlink"); - third - .write_all( - br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#, - ) - .unwrap(); - third.write_all(b"\n").unwrap(); - third.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when source metadata is dropped and cleanup fails" - ); - server.join().unwrap(); - assert!( - managed_checkout.exists(), - "checkout should stay when server cleanup fails" - ); - - cleanup_test_base(&base); -} - -#[test] -fn cli_rejects_protocol_mismatch_before_wait_agent_status_request() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut first_stream, _) = listener.accept().unwrap(); - let mut first_line = String::new(); - let mut first_reader = BufReader::new(first_stream.try_clone().unwrap()); - first_reader.read_line(&mut first_line).unwrap(); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - if first_request["method"] == "ping" { - write_fake_pong(&mut first_stream, &first_request, "0.7.1", 14); - } else { - first_stream - .write_all( - br#"{"id":"cli:wait:agent-status","error":{"code":"not_implemented","message":"method not implemented yet"}}"#, - ) - .unwrap(); - } - if first_request["method"] != "ping" { - first_stream.write_all(b"\n").unwrap(); - first_stream.flush().unwrap(); - } - - let mut second_line = None; - listener.set_nonblocking(true).unwrap(); - let deadline = Instant::now() + Duration::from_millis(250); - while Instant::now() < deadline { - match listener.accept() { - Ok((second_stream, _)) => { - let mut line = String::new(); - let mut reader = BufReader::new(second_stream); - reader.read_line(&mut line).unwrap(); - second_line = Some(line); - break; - } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("second accept failed: {err}"), - } - } - - (first_line, second_line) - }); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "idle", - "--timeout", - "5000", - ], - ); - - assert_eq!(waited.status.code(), Some(1)); - assert!(waited.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["id"], "cli:wait:agent-status"); - assert_eq!(error["error"]["code"], "protocol_mismatch"); - let message = error["error"]["message"].as_str().unwrap(); - assert!( - message.contains(&format!("client protocol {CURRENT_PROTOCOL}")), - "message: {message}" - ); - assert!(message.contains("server protocol 14"), "message: {message}"); - assert!(message.contains("restart"), "message: {message}"); - - let (first_line, second_line) = server.join().unwrap(); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - assert_eq!(first_request["method"], "ping"); - assert!( - second_line.is_none(), - "mismatched CLI sent an operational request: {second_line:?}" - ); - - cleanup_test_base(&base); -} - -#[test] -fn cli_allows_same_protocol_different_version_and_preserves_server_error() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut ping_stream, _) = listener.accept().unwrap(); - let mut ping_line = String::new(); - BufReader::new(ping_stream.try_clone().unwrap()) - .read_line(&mut ping_line) - .unwrap(); - let ping: serde_json::Value = serde_json::from_str(&ping_line).unwrap(); - assert_eq!(ping["method"], "ping"); - write_fake_pong( - &mut ping_stream, - &ping, - "0.7.1-compatible-build", - CURRENT_PROTOCOL, - ); - - let (mut operation_stream, _) = listener.accept().unwrap(); - let mut operation_line = String::new(); - BufReader::new(operation_stream.try_clone().unwrap()) - .read_line(&mut operation_line) - .unwrap(); - let operation: serde_json::Value = serde_json::from_str(&operation_line).unwrap(); - assert_eq!(operation["method"], "events.wait"); - operation_stream - .write_all( - br#"{"id":"cli:wait:agent-status","error":{"code":"not_implemented","message":"compatible server error"}}"#, - ) - .unwrap(); - operation_stream.write_all(b"\n").unwrap(); - operation_stream.flush().unwrap(); - }); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "idle", - "--timeout", - "5000", - ], - ); - - assert_eq!(waited.status.code(), Some(1)); - assert!(waited.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["id"], "cli:wait:agent-status"); - assert_eq!(error["error"]["code"], "not_implemented"); - assert_eq!(error["error"]["message"], "compatible server error"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn agent_wait_reports_poll_transport_failure_as_structured_error() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - for request_number in 0..2 { - let (mut ping_stream, _) = listener.accept().unwrap(); - let mut ping_line = String::new(); - BufReader::new(ping_stream.try_clone().unwrap()) - .read_line(&mut ping_line) - .unwrap(); - let ping: serde_json::Value = serde_json::from_str(&ping_line).unwrap(); - assert_eq!(ping["method"], "ping"); - write_fake_pong(&mut ping_stream, &ping, "current", CURRENT_PROTOCOL); - - let (mut get_stream, _) = listener.accept().unwrap(); - let mut get_line = String::new(); - BufReader::new(get_stream.try_clone().unwrap()) - .read_line(&mut get_line) - .unwrap(); - let get: serde_json::Value = serde_json::from_str(&get_line).unwrap(); - assert_eq!(get["method"], "agent.get"); - if request_number == 0 { - writeln!( - get_stream, - "{}", - serde_json::json!({ - "id": get["id"], - "result": { - "type": "agent_info", - "agent": { - "name": "worker", - "pane_id": "w1:p1", - "terminal_id": "term_1", - "agent_status": "working" - } - } - }) - ) - .unwrap(); - get_stream.flush().unwrap(); - } - } - }); - - let waited = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "5000"], - ); - assert_eq!(waited.status.code(), Some(1)); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["error"]["code"], "agent_wait_transport_failed"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn agent_wait_rechecks_protocol_before_subscription() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut first_ping_stream, _) = listener.accept().unwrap(); - let mut first_ping_line = String::new(); - BufReader::new(first_ping_stream.try_clone().unwrap()) - .read_line(&mut first_ping_line) - .unwrap(); - let first_ping: serde_json::Value = serde_json::from_str(&first_ping_line).unwrap(); - assert_eq!(first_ping["method"], "ping"); - write_fake_pong( - &mut first_ping_stream, - &first_ping, - "current", - CURRENT_PROTOCOL, - ); - - let (mut get_stream, _) = listener.accept().unwrap(); - let mut get_line = String::new(); - BufReader::new(get_stream.try_clone().unwrap()) - .read_line(&mut get_line) - .unwrap(); - let get_request: serde_json::Value = serde_json::from_str(&get_line).unwrap(); - assert_eq!(get_request["method"], "agent.get"); - get_stream - .write_all( - br#"{"id":"cli:agent:wait:resolve","result":{"type":"agent_info","agent":{"name":"worker","pane_id":"w1:p1","terminal_id":"t1","agent_status":"working"}}}"#, - ) - .unwrap(); - get_stream.write_all(b"\n").unwrap(); - get_stream.flush().unwrap(); - - let (mut second_ping_stream, _) = listener.accept().unwrap(); - let mut second_ping_line = String::new(); - BufReader::new(second_ping_stream.try_clone().unwrap()) - .read_line(&mut second_ping_line) - .unwrap(); - let second_ping: serde_json::Value = serde_json::from_str(&second_ping_line).unwrap(); - assert_eq!(second_ping["method"], "ping"); - write_fake_pong(&mut second_ping_stream, &second_ping, "0.7.1", 14); - - listener.set_nonblocking(true).unwrap(); - let deadline = Instant::now() + Duration::from_millis(250); - while Instant::now() < deadline { - match listener.accept() { - Ok(_) => panic!("agent wait polled agent state after protocol mismatch"), - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(err) => panic!("accept failed: {err}"), - } - } - }); - - let waited = run_cli( - &socket_path, - &["agent", "wait", "worker", "--timeout", "5000"], - ); - - assert_eq!(waited.status.code(), Some(1)); - assert!(waited.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&waited.stderr).unwrap(); - assert_eq!(error["id"], "cli:agent:wait"); - assert_eq!(error["error"]["code"], "protocol_mismatch"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn server_live_handoff_bypasses_protocol_guard() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut line = String::new(); - BufReader::new(stream.try_clone().unwrap()) - .read_line(&mut line) - .unwrap(); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(request["method"], "server.live_handoff"); - stream - .write_all(br#"{"id":"cli:server:live-handoff","result":{"type":"ok"}}"#) - .unwrap(); - stream.write_all(b"\n").unwrap(); - stream.flush().unwrap(); - }); - - let handoff = run_cli(&socket_path, &["server", "live-handoff"]); - assert!( - handoff.status.success(), - "stderr: {}", - String::from_utf8_lossy(&handoff.stderr) - ); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn plugin_list_preserves_protocol_mismatch_envelope() { - let base = unique_test_dir(); - fs::create_dir_all(&base).unwrap(); - let socket_path = base.join("herdr.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut line = String::new(); - BufReader::new(stream.try_clone().unwrap()) - .read_line(&mut line) - .unwrap(); - let request: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(request["method"], "ping"); - write_fake_pong(&mut stream, &request, "0.7.1", 14); - }); - - let listed = run_cli(&socket_path, &["plugin", "list", "--json"]); - assert_eq!(listed.status.code(), Some(1)); - assert!(listed.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&listed.stderr).unwrap(); - assert_eq!(error["id"], "cli:plugin"); - assert_eq!(error["error"]["code"], "protocol_mismatch"); - server.join().unwrap(); - cleanup_test_base(&base); -} - -#[test] -fn wait_agent_status_exits_immediately_when_status_already_matches() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_immediate_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - let workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - let pane_id = format!("{workspace_id}:p1"); - - let reported = send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#, - pane_id - ), - ); - assert_eq!(reported["result"]["type"], "ok"); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "idle", - "--timeout", - "1000", - ], - ); - assert!( - waited.status.success(), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); - assert_eq!(waited_json["event"], "pane.agent_status_changed"); - assert_eq!(waited_json["data"]["agent_status"], "idle"); - assert_eq!(waited_json["data"]["agent"], "pi"); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn wait_agent_status_times_out_when_status_does_not_match() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - - let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_timeout_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - assert_eq!(created["result"]["type"], "workspace_created"); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "blocked", - "--timeout", - "100", - ], - ); - assert!(!waited.status.success()); - assert!( - String::from_utf8_lossy(&waited.stderr) - .contains("timed out waiting for agent status change"), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - - cleanup_spawned_herdr(herdr, base); -} - -#[test] -fn wait_agent_status_exits_when_done_status_matches() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("herdr.sock"); - let bin_dir = base.join("bin"); - - fs::create_dir_all(&bin_dir).unwrap(); - let fake_pi = bin_dir.join("pi"); - fs::write( - &fake_pi, - "#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n", - ) - .unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&fake_pi).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&fake_pi, perms).unwrap(); - } - - let inherited_path = std::env::var("PATH").unwrap_or_default(); - let path_override = format!("{}:{}", bin_dir.display(), inherited_path); - let herdr = spawn_herdr_with_path( - &config_home, - &runtime_dir, - &socket_path, - Some(Path::new(&path_override)), - ); - - wait_for_socket(&socket_path, Duration::from_secs(5)); - - let created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_status_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, - base.display() - ), - ); - let workspace_id = created["result"]["workspace"]["workspace_id"] - .as_str() - .unwrap() - .to_string(); - - let tab_created = send_request( - &socket_path, - &format!( - r#"{{"id":"req_cli_status_2","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#, - workspace_id - ), - ); - assert_eq!(tab_created["result"]["type"], "tab_created"); - - let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]); - assert!(start_pi.status.success()); - - let waited = run_cli( - &socket_path, - &[ - "wait", - "agent-status", - "1-1", - "--status", - "done", - "--timeout", - "10000", - ], - ); - assert!( - waited.status.success(), - "stderr: {}", - String::from_utf8_lossy(&waited.stderr) - ); - let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); - assert_eq!(waited_json["event"], "pane.agent_status_changed"); - assert_eq!(waited_json["data"]["agent_status"], "done"); - assert_eq!(waited_json["data"]["agent"], "pi"); - - cleanup_spawned_herdr(herdr, base); -} diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 9f607195..c93898b6 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1212,6 +1212,94 @@ fn live_handoff_accepts_canonical_pane_id_from_child_env() { } #[test] +fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() { + 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 old_session = base.join("old-session.jsonl"); + let new_session = base.join("new-session.jsonl"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:agent:report", + "method": "pane.report_agent", + "params": { + "pane_id": pane_id, + "source": "herdr:pi", + "agent": "pi", + "state": "idle", + "seq": 1, + "agent_session_path": old_session + } + }), + )); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:agent:rename", + "method": "agent.rename", + "params": {"target": pane_id, "name": "reviewer"} + }), + )); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:agent:new-session", + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": "herdr:pi", + "agent": "pi", + "seq": 2, + "agent_session_path": new_session, + "session_start_source": "new" + } + }), + )); + let old_name = request( + &api_socket, + serde_json::json!({ + "id": "test:agent:get-old-name", + "method": "agent.get", + "params": {"target": "reviewer"} + }), + ); + assert_eq!(old_name["error"]["code"], "agent_not_found", "{old_name}"); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] fn live_handoff_keeps_agent_started_pane_after_agent_exits() { use std::os::unix::fs::PermissionsExt; |
