diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 53cd1a99..71984d1e 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,10 +3,12 @@ ## 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 `ui.sidebar_start_collapsed` to launch Herdr with the sidebar collapsed. (#1463) - Added macOS support for the `HERDR_AGENT=` foreground-process hint, allowing agents hidden behind host-visible wrappers such as `nono` to use the named agent's screen manifest. (#679) ### Fixed +- Named agent prompts now honor live bracketed-paste mode before sending Enter, preserving OpenCode text such as `A != B` instead of triggering shell mode. (#1525) - New panes, tabs, layouts, and workspaces using `new_cwd = "follow"` now inherit the foreground process-group leader's working directory instead of an unrelated helper process directory. (#1472) - Noninteractive update, plugin, integration, sound, custom-command, and Git subprocesses no longer flash console windows on Windows. (#1468) - Live handoff now preserves installed plugins and no longer lets the next plugin installation overwrite the existing registry. (#893) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index a9712784..cb0c0b72 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "protocol": 16, + "protocol": 17, "schema_version": 1, "schemas": { "error_response": { @@ -1176,6 +1176,21 @@ }, "request": { "$defs": { + "AgentPromptParams": { + "properties": { + "target": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "target", + "text" + ], + "type": "object" + }, "AgentReadParams": { "properties": { "format": { @@ -1241,57 +1256,35 @@ }, "AgentStartParams": { "properties": { - "argv": { + "args": { "items": { "type": "string" }, "type": "array" }, - "cwd": { - "type": [ - "string", - "null" - ] - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "focus": { - "default": false, - "type": "boolean" + "kind": { + "type": "string" }, "name": { "type": "string" }, - "split": { - "anyOf": [ - { - "$ref": "#/schemas/request/$defs/SplitDirection" - }, - { - "type": "null" - } - ] + "pane_id": { + "type": "string" }, - "tab_id": { + "timeout_ms": { + "description": "Startup timeout in milliseconds. Values must be greater than 3000 and at most 300000.", + "format": "uint64", + "minimum": 0, "type": [ - "string", - "null" - ] - }, - "workspace_id": { - "type": [ - "string", + "integer", "null" ] } }, "required": [ "name", - "argv" + "kind", + "pane_id" ], "type": "object" }, @@ -4567,6 +4560,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "agent.prompt", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/AgentPromptParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -5604,6 +5613,12 @@ "null" ] }, + "interactive_ready": { + "type": "boolean" + }, + "launch_pending": { + "type": "boolean" + }, "name": { "type": [ "string", @@ -5621,6 +5636,12 @@ "screen_detection_skipped": { "type": "boolean" }, + "state_change_seq": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, "state_labels": { "additionalProperties": { "type": "string" @@ -8294,6 +8315,28 @@ ], "type": "object" }, + { + "properties": { + "agent": { + "$ref": "#/schemas/success_response/$defs/AgentInfo" + }, + "baseline_state_change_seq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "agent_prompted", + "type": "string" + } + }, + "required": [ + "type", + "agent", + "baseline_state_change_seq" + ], + "type": "object" + }, { "properties": { "agents": { diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index f62564e1..958852fa 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -133,21 +133,35 @@ Agent sidebar rows can also opt into `terminal_title` or `terminal_title_strippe ## Start agents from the CLI -Use `herdr agent ...` commands when you want a terminal to be treated as an agent target. Agent targets show up in `agent list`, can be read or sent input by agent name, can be waited on by agent state, and can be directly attached. - -Spawn an agent into Herdr from a script: +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 agent start reviewer --cwd ~/project --split right -- pi +herdr pane split --current --direction right --no-focus +herdr agent start reviewer --kind pi --pane w1:p2 ``` -You can place that agent in a specific workspace or tab: +`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 docs --workspace w1 --tab w1:t1 -- claude +herdr agent start reviewer --kind pi --pane w1:p2 -- --name scratch --no-session ``` -Use `herdr pane ...` commands for ordinary terminals, servers, tests, shells, and low-level terminal input. For example, use `pane split` and `pane run` for `cargo test`, not `agent start`, unless that terminal is intentionally being treated as an agent target. +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 diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index a7c11d92..4d668df9 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -205,7 +205,7 @@ 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`. -`pane run` submits text plus Enter atomically. Prefer it over `send-text` plus `send-keys Enter` for commands. +`pane run` honors live bracketed-paste mode and submits text plus Enter atomically. Prefer it over `send-text` plus `send-keys Enter` for commands; the separate send operations remain low-level and non-submitting. Report agent state from custom hooks: @@ -258,18 +258,21 @@ herdr agent list herdr agent get herdr agent read [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] herdr agent send +herdr agent prompt [--wait] [--timeout MS] herdr agent rename |--clear herdr agent focus -herdr agent wait --status [--timeout MS] +herdr agent wait [--timeout MS] herdr agent attach [--takeover] -herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- +herdr agent start --kind KIND --pane ID [--timeout MS] [-- ] herdr agent explain [--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 read` reads the resolved terminal stream. `agent send` writes literal text to that stream. `agent get`, `agent focus`, `agent wait`, and `agent attach` require the resolved terminal to have agent identity. `agent rename` can assign that identity. +`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. + +`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 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. diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 8b311b3c..14f61d03 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -133,21 +133,35 @@ herdr pane report-metadata w1:p1 \ ## CLI からエージェントを起動する -ターミナルをエージェントターゲットとして扱いたいときは `herdr agent ...` コマンドを使います。エージェントターゲットは `agent list` に表示され、エージェント名で読み取りや入力送信ができ、エージェント状態で wait でき、直接アタッチできます。 - -スクリプトから Herdr にエージェントを起動します: +まずワークスペース、タブ、ペイン、レイアウトの各コマンドでトポロジーを作成します。次に、既存のシェルペインを一意な名前を持つ対応済み対話型エージェントとして起動します: ```bash -herdr agent start reviewer --cwd ~/project --split right -- pi +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 docs --workspace w1 --tab w1:t1 -- claude +herdr agent start reviewer --kind pi --pane w1:p2 -- --name scratch --no-session ``` -通常のターミナル、サーバー、テスト、シェル、低レベルなターミナル入力には `herdr pane ...` コマンドを使ってください。たとえば `cargo test` には `agent start` ではなく `pane split` と `pane run` を使います。そのターミナルを意図的にエージェントターゲットとして扱うのでない限り。 +名前でプロンプトを送信します。`--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 ...` コマンドを使ってください。 ## エージェントに直接アタッチする 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 9aa1ebe5..dfa82472 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -191,7 +191,7 @@ herdr pane run `` は 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` のエイリアスとして受け付けられます。 -`pane run` はテキストと Enter をアトミックに送信します。コマンドには `send-text` + `send-keys Enter` よりこちらを使ってください。 +`pane run` は現在の bracketed paste モードを尊重し、テキストと Enter をアトミックに送信します。コマンドには `send-text` + `send-keys Enter` よりこちらを使ってください。個別の送信操作は低レベルのままで、コマンドを送信しません。 カスタムフックからエージェント状態を報告する: @@ -242,18 +242,21 @@ herdr agent list herdr agent get herdr agent read [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] herdr agent send +herdr agent prompt [--wait] [--timeout MS] herdr agent rename |--clear herdr agent focus -herdr agent wait --status [--timeout MS] +herdr agent wait [--timeout MS] herdr agent attach [--takeover] -herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- +herdr agent start --kind KIND --pane ID [--timeout MS] [-- ] herdr agent explain [--json|--verbose] herdr agent explain --file PATH --agent LABEL [--json|--verbose] ``` エージェントターゲットには、ターミナル ID、一意なエージェント名、検出または報告されたエージェントラベル、レガシーなペイン ID が使えます。名前とラベルはエージェントのアイデンティティです。ターミナル ID とレガシーペイン ID は低レベルな逃げ道です。 -`agent read` は解決されたターミナルストリームを読みます。`agent send` はそのストリームにリテラルのテキストを書き込みます。`agent get`、`agent focus`、`agent wait`、`agent attach` は、解決されたターミナルがエージェントのアイデンティティを持つことを要求します。`agent rename` はそのアイデンティティを割り当てられます。 +`agent start` は既存の利用可能なシェルペインを起動対象にし、トポロジーは別に作成します。kind は Herdr の標準対話型実行ファイルを選び、`--` 以降の引数はその実行ファイルへ渡されます。`agent prompt` は一意なアクティブエージェント名を要求し、現在の bracketed paste モードを尊重してテキストと Enter をアトミックに送信します。`--wait` を付けると、その送信より後の idle、done、または blocked 状態まで待ちます。単独の `agent wait` は idle、done、blocked なら即座に返り、それ以外では working の間だけ待ちます。 + +`agent read` は解決されたターミナルストリームを読みます。`agent send` はターンを送信せずにリテラルテキストを書き込みます。テキストを入力モードのキーバインドから保護して Enter と一緒に送る場合は `agent prompt` を使ってください。`agent get`、`agent focus`、`agent attach` はより広いターゲット形式を受け付けます。`agent rename` は自動検出されたエージェントに名前を割り当てられます。 `agent explain` は、スクリーン検出が使うのと同じ下部バッファの検出スナップショットの分類を実行中のサーバーに依頼します。そのためライブの出力はサーバーのアクティブなマニフェストキャッシュを反映します。これは `agent.explain` ソケットメソッドを使うので、Herdr のアップグレード後にライブ explain を使う前に、サーバーを再起動するか更新済みサーバーへハンドオフしてください。保存済みフィクスチャをローカルで説明するには `--file PATH --agent LABEL` を使います。デフォルトの出力には、エージェント、最終状態、マニフェストのソースとバージョン、リージョンの証拠付きでマッチしたルール、そしてフォールバック・スキップ・警告の理由が表示されます。`--verbose` を付けると、可視の証拠フラグ、キャッシュされたリモートバージョン、ローカルオーバーライドのシャドーイング、リモート更新状況、マッチャーとリージョンの証拠付きの評価済みルール全リストが表示されます。issue の報告やテストには `--json` を付けてください。 diff --git a/docs/next/website/src/content/docs/ja/session-state.mdx b/docs/next/website/src/content/docs/ja/session-state.mdx index 6b349159..2567fc3f 100644 --- a/docs/next/website/src/content/docs/ja/session-state.mdx +++ b/docs/next/website/src/content/docs/ja/session-state.mdx @@ -93,6 +93,8 @@ Herdr が resume するのは、現行の公式 Herdr インテグレーショ これはスナップショット復元、ペイン履歴リプレイ、エージェントネイティブのセッション復元とは異なります。ハンドオフは現在のプロセスを生かし続けようとします。他の経路は、古いサーバーが停止した後に状態を再構築します。 +ハンドオフが保護するのは、ペインの PTY とプロセス、エージェントの識別情報と永続メタデータ、交換後のサーバーに必要なプラグイン/セッション状態など、サーバーが所有する長寿命のセッション状態です。交換境界をまたぐ一時的な協調状態は保持しません。処理中の CLI/API リクエスト、wait、購読ストリーム、クライアントソケット、ペイン間メッセージは中断される可能性があるため、クライアントは再接続して再試行してください。 + ライブハンドオフは実験的機能で、オプトインです: ```bash diff --git a/docs/next/website/src/content/docs/ja/socket-api.mdx b/docs/next/website/src/content/docs/ja/socket-api.mdx index 975336ca..be2ad285 100644 --- a/docs/next/website/src/content/docs/ja/socket-api.mdx +++ b/docs/next/website/src/content/docs/ja/socket-api.mdx @@ -36,7 +36,7 @@ herdr api schema --output herdr-api.schema.json - ワークスペースの作成、一覧、フォーカス、名前変更、クローズ - タブの作成、一覧、フォーカス、名前変更、クローズ - ペインの一覧、調査、分割、入れ替え、フォーカス、リサイズ、名前変更、読み取り、クローズ、入力送信 -- CLI ヘルパーを通じたエージェントの一覧、調査、読み取り、送信、名前変更、フォーカス、起動、アタッチ +- CLI ヘルパーを通じたエージェントの一覧、調査、読み取り、プロンプト、待機、名前変更、フォーカス、起動、アタッチ - フックやプラグインからのカスタムエージェント状態の報告 - イベントの購読と、出力や状態変化の待機 - 組み込みインテグレーションのインストールとアンインストール @@ -102,7 +102,7 @@ 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.rename`、`agent.focus`、`agent.start` | +| エージェント | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send`、`agent.prompt`、`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` | diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index a2ac187e..811a346d 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -93,6 +93,8 @@ Live handoff is for update and remote attach flows that need to replace a runnin This is different from snapshot restore, pane history replay, and native agent session restore. Handoff tries to keep the current processes alive. The others reconstruct state after the old server has already stopped. +Handoff protects long-lived server-owned session state: pane PTYs and processes, agent identity and durable metadata, and plugin/session state needed by the replacement server. It does not preserve transient coordination across the replacement boundary. In-flight CLI or API requests, waits, subscription streams, client sockets, and pane-to-pane messages may be interrupted; clients should reconnect and retry them. + Live handoff is experimental and opt-in: ```bash diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index cc8d5675..82778d5b 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -40,7 +40,7 @@ The socket API can: - create, list, focus, rename, and close workspaces - create, list, focus, rename, and close tabs - list, inspect, split, swap, focus, resize, rename, read, close, and send input to panes -- list, inspect, read, send to, rename, focus, start, and attach agents through CLI helpers +- list, inspect, read, prompt, wait on, rename, focus, start, and attach agents through CLI helpers - report custom agent state from hooks and plugins - subscribe to events and wait for output or state changes - install and uninstall built-in integrations @@ -106,7 +106,7 @@ 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.rename`, `agent.focus`, `agent.start` | +| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send`, `agent.prompt`, `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` | 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 e7fb0b64..ba6e96b7 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -133,21 +133,35 @@ herdr pane report-metadata w1:p1 \ ## 从 CLI 启动智能体 -当你希望一个终端被当作智能体目标时,使用 `herdr agent ...` 命令。智能体目标会出现在 `agent list` 中,可以按智能体名称读取或发送输入,可以按智能体状态等待,也可以被直接附加。 - -从脚本向 Herdr 中启动一个智能体: +先用工作区、标签页、窗格和布局命令创建拓扑,再把一个现有 shell 窗格启动为具有唯一名称的受支持交互式智能体: ```bash -herdr agent start reviewer --cwd ~/project --split right -- pi +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 docs --workspace w1 --tab w1:t1 -- claude +herdr agent start reviewer --kind pi --pane w1:p2 -- --name scratch --no-session ``` -普通终端、服务器、测试、shell 和底层终端输入请使用 `herdr pane ...` 命令。例如跑 `cargo test` 用 `pane split` 和 `pane run`,而不是 `agent start`,除非那个终端就是有意要当作智能体目标。 +按名称提交提示。`--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 ...` 命令。 ## 直接附加到智能体 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 6275adb7..61666ba8 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -191,7 +191,7 @@ herdr pane run `` 使用 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` 的别名被接受。 -`pane run` 把文本和回车作为一个原子操作提交。发送命令时优先用它,而不是 `send-text` 加 `send-keys Enter`。 +`pane run` 会遵循当前的 bracketed paste 模式,并把文本和 Enter 作为一个原子操作提交。发送命令时优先用它,而不是 `send-text` 加 `send-keys Enter`;单独的发送操作仍是底层操作,不会提交命令。 从自定义钩子上报智能体状态: @@ -242,18 +242,21 @@ herdr agent list herdr agent get herdr agent read [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] herdr agent send +herdr agent prompt [--wait] [--timeout MS] herdr agent rename |--clear herdr agent focus -herdr agent wait --status [--timeout MS] +herdr agent wait [--timeout MS] herdr agent attach [--takeover] -herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- +herdr agent start --kind KIND --pane ID [--timeout MS] [-- ] herdr agent explain [--json|--verbose] herdr agent explain --file PATH --agent LABEL [--json|--verbose] ``` 智能体目标可以是终端 ID、唯一的智能体名称、检测到或上报的智能体标签,以及旧式窗格 ID。名称和标签是智能体的身份。终端 ID 和旧式窗格 ID 是底层的逃生舱。 -`agent read` 读取解析出的终端流。`agent send` 向该流写入字面文本。`agent get`、`agent focus`、`agent wait` 和 `agent attach` 要求解析出的终端具有智能体身份。`agent rename` 可以赋予这个身份。 +`agent start` 会在现有可用 shell 窗格中启动智能体;拓扑必须单独创建。kind 选择 Herdr 的标准交互式可执行文件,`--` 之后的参数会传给该程序。`agent prompt` 要求唯一的活动智能体名称,遵循当前的 bracketed paste 模式,并原子地提交文本和 Enter。加上 `--wait` 后,它会等待该提交之后的 idle、done 或 blocked 状态。单独的 `agent wait` 在当前为 idle、done 或 blocked 时立即返回,否则只在智能体处于 working 时等待。 + +`agent read` 读取解析出的终端流。`agent send` 只写入字面文本,不会提交一轮对话;需要保护文本不触发输入模式快捷键并发送 Enter 时,请使用 `agent prompt`。`agent get`、`agent focus` 和 `agent attach` 接受更广泛的目标形式。`agent rename` 可以为自动检测到的智能体分配名称。 `agent explain` 请求运行中的服务器对屏幕检测所用的同一份底部缓冲区检测快照进行分类,因此实时输出反映服务器生效的清单缓存。因为它使用 `agent.explain` socket 方法,升级 Herdr 后,请先重启或交接到更新后的服务器,再使用实时 explain。用 `--file PATH --agent LABEL` 可以改为在本地解释一份保存的样本。默认输出显示智能体、最终状态、清单来源和版本、匹配的规则及其区域证据,以及任何回退、跳过或警告原因。加 `--verbose` 可以看到可见证据标志、缓存的远程版本、本地覆盖的遮蔽情况、远程更新状态,以及带匹配器和区域证据的完整已评估规则列表。提交问题报告或写测试时加 `--json`。 diff --git a/docs/next/website/src/content/docs/zh-cn/session-state.mdx b/docs/next/website/src/content/docs/zh-cn/session-state.mdx index c87016ec..cf796d0c 100644 --- a/docs/next/website/src/content/docs/zh-cn/session-state.mdx +++ b/docs/next/website/src/content/docs/zh-cn/session-state.mdx @@ -93,6 +93,8 @@ Herdr 只会恢复那些通过当前官方 Herdr 集成上报了原生会话引 这与快照恢复、窗格历史回放和智能体原生会话恢复不同。交接尝试让当前进程活下去,其他路径则是在旧服务器已经停止之后重建状态。 +交接保护的是由服务器拥有的长期会话状态:窗格 PTY 和进程、智能体身份与持久元数据,以及替换服务器所需的插件/会话状态。它不会跨替换边界保留临时协调状态。进行中的 CLI 或 API 请求、等待、订阅流、客户端套接字和窗格间消息可能会中断;客户端应重新连接并重试。 + 实时交接是实验性功能,需要主动开启: ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/socket-api.mdx b/docs/next/website/src/content/docs/zh-cn/socket-api.mdx index 95f89be6..89c2e771 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 @@ -36,7 +36,7 @@ socket API 可以: - 创建、列出、聚焦、重命名和关闭工作区 - 创建、列出、聚焦、重命名和关闭标签页 - 列出、检查、分割、交换、聚焦、调整、重命名、读取、关闭窗格并向其发送输入 -- 通过 CLI 辅助命令列出、检查、读取、发送、重命名、聚焦、启动和附加智能体 +- 通过 CLI 辅助命令列出、检查、读取、提示、等待、重命名、聚焦、启动和附加智能体 - 从钩子和插件上报自定义智能体状态 - 订阅事件并等待输出或状态变化 - 安装和卸载内置集成 @@ -102,7 +102,7 @@ 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.rename`、`agent.focus`、`agent.start` | +| 智能体 | `agent.list`、`agent.get`、`agent.read`、`agent.explain`、`agent.send`、`agent.prompt`、`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` | diff --git a/src/api/client.rs b/src/api/client.rs index 38baf807..326ebc7d 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -7,8 +7,7 @@ use interprocess::local_socket::traits::Stream as _; use serde::de::DeserializeOwned; use crate::api::schema::{ - ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult, - SubscriptionEventEnvelope, SuccessResponse, + ErrorResponse, Method, PingParams, Request, ResponseResult, SuccessResponse, }; use crate::ipc::LocalStream; @@ -75,37 +74,6 @@ impl ApiClient { read_json_line(&mut reader) } - #[allow(dead_code)] // Kept as the typed subscription API; CLI wait paths use subscribe_value to preserve raw ack errors. - pub fn subscribe( - &self, - id: impl Into, - params: EventsSubscribeParams, - read_timeout: Option, - ) -> Result<(SuccessResponse, EventStream), ApiClientError> { - let request = Request { - id: id.into(), - method: Method::EventsSubscribe(params), - }; - let (ack, stream) = self.subscribe_value(&request, read_timeout)?; - Ok((parse_response_value(ack)?, stream)) - } - - pub fn subscribe_value( - &self, - request: &Request, - read_timeout: Option, - ) -> Result<(serde_json::Value, EventStream), ApiClientError> { - let mut stream = self.connect()?; - write_request(&mut stream, request)?; - if let Some(timeout) = read_timeout { - set_timeout_best_effort(&stream, TimeoutKind::Recv, timeout)?; - } - - let mut reader = BufReader::new(stream); - let ack = read_json_line(&mut reader)?; - Ok((ack, EventStream { reader })) - } - pub fn status(&self) -> Result { let response = self.request(Request { id: "api-client:status".into(), @@ -152,23 +120,6 @@ fn set_timeout_best_effort( } } -pub struct EventStream { - reader: BufReader, -} - -impl EventStream { - pub fn next_value(&mut self) -> Result, ApiClientError> { - read_optional_json_line(&mut self.reader) - } - - pub fn next_event(&mut self) -> Result, ApiClientError> { - self.next_value()? - .map(serde_json::from_value) - .transpose() - .map_err(ApiClientError::Json) - } -} - #[derive(Debug)] pub enum ApiClientError { Io(io::Error), @@ -222,22 +173,6 @@ fn read_json_line( serde_json::from_str(&line).map_err(ApiClientError::Json) } -fn read_optional_json_line( - reader: &mut BufReader, -) -> Result, ApiClientError> { - let mut line = String::new(); - let read = reader.read_line(&mut line)?; - if read == 0 { - return Ok(None); - } - if line.trim().is_empty() { - return Err(ApiClientError::EmptyResponse); - } - serde_json::from_str(&line) - .map(Some) - .map_err(ApiClientError::Json) -} - #[derive(serde::Deserialize)] #[serde(untagged)] enum WireResponse { diff --git a/src/api/mod.rs b/src/api/mod.rs index 364d5fc8..618fa4f9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -44,6 +44,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::AgentRename(_) | Method::AgentFocus(_) | Method::AgentStart(_) + | Method::AgentPrompt(_) | Method::PaneSplit(_) | Method::PaneSwap(_) | Method::PaneMove(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 4f5df34f..41f372e3 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -117,6 +117,8 @@ pub enum Method { AgentFocus(AgentTarget), #[serde(rename = "agent.start")] AgentStart(AgentStartParams), + #[serde(rename = "agent.prompt")] + AgentPrompt(AgentPromptParams), #[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 969383c4..8592e56b 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::common::{AgentStatus, ReadFormat, ReadSource, SplitDirection}; +use super::common::{AgentStatus, ReadFormat, ReadSource}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AgentReadParams { @@ -32,19 +32,19 @@ pub struct AgentRenameParams { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AgentStartParams { pub name: String, + pub kind: String, + pub pane_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Startup timeout in milliseconds. Values must be greater than 3000 and at most 300000. #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tab_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub split: Option, - #[serde(default)] - pub focus: bool, - pub argv: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AgentPromptParams { + pub target: String, + pub text: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -76,6 +76,12 @@ pub struct AgentInfo { pub tab_id: String, pub pane_id: String, pub focused: bool, + #[serde(default, skip_serializing_if = "super::is_false")] + pub launch_pending: bool, + #[serde(default, skip_serializing_if = "super::is_false")] + pub interactive_ready: bool, + #[serde(default)] + pub state_change_seq: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 129c8f91..ad058191 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -101,6 +101,10 @@ pub enum ResponseResult { agent: AgentInfo, argv: Vec, }, + AgentPrompted { + agent: AgentInfo, + baseline_state_change_seq: u64, + }, AgentList { agents: Vec, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index bc044ee9..232c2b73 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -61,6 +61,41 @@ fn request_uses_dot_method_names() { assert_eq!(json["method"], "workspace.create"); } +#[test] +fn agent_start_and_prompt_requests_round_trip() { + let start = Request { + id: "start".into(), + method: Method::AgentStart(AgentStartParams { + name: "reviewer".into(), + kind: "pi".into(), + pane_id: "w1:p2".into(), + args: vec!["--no-session".into()], + timeout_ms: Some(30_000), + }), + }; + let start_json = serde_json::to_value(&start).unwrap(); + assert_eq!(start_json["method"], "agent.start"); + assert_eq!(start_json["params"]["pane_id"], "w1:p2"); + assert_eq!( + serde_json::from_value::(start_json).unwrap(), + start + ); + + let prompt = Request { + id: "prompt".into(), + method: Method::AgentPrompt(AgentPromptParams { + target: "reviewer".into(), + text: "review this".into(), + }), + }; + let prompt_json = serde_json::to_value(&prompt).unwrap(); + assert_eq!(prompt_json["method"], "agent.prompt"); + assert_eq!( + serde_json::from_value::(prompt_json).unwrap(), + prompt + ); +} + #[test] fn bundled_protocol_schema_refs_resolve_inside_bundle() { fn assert_no_standalone_refs(value: &serde_json::Value) { diff --git a/src/api/server.rs b/src/api/server.rs index d159e1dc..ce538853 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -371,6 +371,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::AgentRename(_) => "agent.rename", Method::AgentFocus(_) => "agent.focus", Method::AgentStart(_) => "agent.start", + Method::AgentPrompt(_) => "agent.prompt", Method::PaneSplit(_) => "pane.split", Method::PaneSwap(_) => "pane.swap", Method::PaneMove(_) => "pane.move", diff --git a/src/app/actions.rs b/src/app/actions.rs index 3fedeeb3..b4f352b4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,6 +1,8 @@ //! Pure state mutations on AppState. //! These don't need channels, async, or PTY runtime. +use std::time::Instant; + use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; @@ -2583,7 +2585,7 @@ impl AppState { observed_at, } => self .update_terminal_state(pane_id, |terminal| { - Some(terminal.set_detected_state_with_screen_signals_at( + let mutation = terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, @@ -2591,7 +2593,11 @@ impl AppState { visible_working, process_exited, observed_at, - )) + ); + if process_exited { + terminal.reconcile_managed_agent_at(observed_at, true); + } + Some(mutation) }) .into_iter() .collect(), @@ -2753,11 +2759,13 @@ impl AppState { .attached_terminal_id .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; - let mutation = { + let (mutation, managed_changed) = { let terminal = self.terminals.get_mut(&terminal_id)?; - update(terminal)? + let mutation = update(terminal)?; + let managed_changed = terminal.reconcile_managed_agent_at(Instant::now(), false); + (mutation, managed_changed) }; - if mutation.session_ref_changed { + if mutation.session_ref_changed || managed_changed { self.mark_session_dirty(); } let change = mutation.effective_state_change?; @@ -2785,6 +2793,40 @@ impl AppState { Some(update) } + pub(crate) fn next_managed_agent_deadline(&self) -> Option { + self.terminals + .values() + .filter_map(crate::terminal::TerminalState::next_managed_agent_deadline) + .min() + } + + pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> { + let mut changed_terminals = std::collections::HashSet::new(); + for (terminal_id, terminal) in &mut self.terminals { + if terminal.reconcile_managed_agent_at(now, false) { + changed_terminals.insert(terminal_id.clone()); + } + } + if changed_terminals.is_empty() { + return Vec::new(); + } + self.mark_session_dirty(); + self.workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, workspace)| { + let changed_terminals = &changed_terminals; + workspace.tabs.iter().flat_map(move |tab| { + tab.panes.iter().filter_map(move |(&pane_id, pane)| { + changed_terminals + .contains(&pane.attached_terminal_id) + .then_some((ws_idx, pane_id)) + }) + }) + }) + .collect() + } + pub(crate) fn publish_pane_process_exit_if_agent( &mut self, pane_id: PaneId, diff --git a/src/app/agents.rs b/src/app/agents.rs index 83f97bd4..f95bf77f 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -1,7 +1,13 @@ -use std::path::PathBuf; +use std::time::{Duration, Instant}; -use super::{terminal_targets::TerminalTargetError, App, Mode}; -use crate::api::schema::{AgentStartParams, SplitDirection}; +use bytes::Bytes; + +use super::{terminal_targets::TerminalTargetError, App}; +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); impl App { pub(super) fn collect_agent_infos(&self) -> Vec { @@ -20,6 +26,31 @@ impl App { .collect() } + pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) { + let Ok(resolved) = self.resolve_terminal_target(target) else { + return; + }; + let Some(terminal_id) = self + .state + .workspaces + .get(resolved.ws_idx) + .and_then(|workspace| workspace.terminal_id(resolved.pane_id)) + .cloned() + else { + return; + }; + let changed = self + .state + .terminals + .get_mut(&terminal_id) + .is_some_and(|terminal| terminal.reconcile_managed_agent_at(Instant::now(), false)); + if changed { + self.state.mark_session_dirty(); + self.schedule_session_save(); + self.emit_pane_updated(resolved.ws_idx, resolved.pane_id); + } + } + pub(super) fn agent_info_for_target( &self, target: &str, @@ -79,6 +110,9 @@ impl App { target: target.to_string(), })); }; + if terminal.managed_agent_launch_pending() { + return Err(AgentRenameError::PendingLaunch); + } match normalized_name { Some(name) => { terminal.set_agent_name(name.clone()); @@ -98,14 +132,20 @@ impl App { pub(super) fn start_agent( &mut self, params: AgentStartParams, - extra_env: Vec<(String, String)>, ) -> Result<(crate::api::schema::AgentInfo, Vec), AgentStartError> { let name = params.name.trim().to_string(); if name.is_empty() { return Err(AgentStartError::InvalidName); } - if params.argv.is_empty() { - return Err(AgentStartError::EmptyArgv); + let Some(kind) = crate::detect::parse_agent_label(¶ms.kind) else { + return Err(AgentStartError::UnsupportedKind(params.kind)); + }; + if params + .args + .iter() + .any(|arg| arg.chars().any(char::is_control)) + { + return Err(AgentStartError::InvalidArgument); } let conflicts = self.agent_name_conflicts(&name, ""); if !conflicts.is_empty() { @@ -114,94 +154,68 @@ impl App { candidates: conflicts, }); } - - let cwd = params - .cwd - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from("/")); - let argv = params.argv; - let focus = params.focus; - let (rows, cols) = self.state.estimate_pane_size(); - - let (ws_idx, tab_idx, pane_id) = if let Some(tab_id) = params.tab_id { - let (ws_idx, tab_idx) = - self.parse_tab_id(&tab_id) - .ok_or_else(|| AgentStartError::TargetNotFound { - target: tab_id.clone(), - })?; - if let Some(workspace_id) = params.workspace_id.as_deref() { - let requested_ws_idx = self.parse_workspace_id(workspace_id).ok_or_else(|| { - AgentStartError::TargetNotFound { - target: workspace_id.to_string(), - } - })?; - if requested_ws_idx != ws_idx { - return Err(AgentStartError::PlacementConflict); - } - } - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? - } else if let Some(workspace_id) = params.workspace_id { - let ws_idx = self.parse_workspace_id(&workspace_id).ok_or_else(|| { - AgentStartError::TargetNotFound { - target: workspace_id.clone(), - } - })?; - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? - } else if self.state.workspaces.is_empty() { - self.spawn_agent_workspace(cwd, rows, cols, &argv, extra_env, focus)? - } else { - let ws_idx = self.state.active.unwrap_or(0); - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return Err(AgentStartError::TargetNotFound(params.pane_id)); }; - let terminal_id = self .state .workspaces .get(ws_idx) - .and_then(|ws| ws.terminal_id(pane_id)) + .and_then(|workspace| workspace.terminal_id(pane_id)) .cloned() - .ok_or_else(|| AgentStartError::SpawnFailed("terminal disappeared".into()))?; - let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { - return Err(AgentStartError::SpawnFailed("terminal disappeared".into())); - }; - terminal.set_agent_name(name.clone()); + .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?; + let terminal = self + .state + .terminals + .get(&terminal_id) + .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?; + if terminal.is_agent_terminal() || terminal.managed_agent_kind().is_some() { + return Err(AgentStartError::TargetBusy(params.pane_id)); + } + let runtime = self + .terminal_runtimes + .get(&terminal_id) + .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; + let shell_name = available_shell_name(runtime) + .ok_or_else(|| AgentStartError::TargetBusy(params.pane_id.clone()))?; + + let mut argv = vec![crate::detect::interactive_agent_executable(kind).to_string()]; + argv.extend(params.args); + let command = crate::platform::interactive_shell_command(&argv, &shell_name) + .ok_or(AgentStartError::InvalidArgument)?; + let bytes = crate::app::api_helpers::encode_api_submission(runtime, &command); + let timeout = Duration::from_millis( + params + .timeout_ms + .unwrap_or(DEFAULT_AGENT_START_TIMEOUT.as_millis() as u64), + ); + if timeout <= AGENT_START_SETTLE_DELAY || timeout > MAX_AGENT_START_TIMEOUT { + return Err(AgentStartError::InvalidTimeout); + } + + let previous_label = terminal.manual_label.clone(); + let now = Instant::now(); + let terminal = self + .state + .terminals + .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(); + self.schedule_session_save(); let agent = self .agent_info(ws_idx, pane_id) - .ok_or_else(|| AgentStartError::SpawnFailed("agent disappeared".into()))?; - debug_assert_eq!(agent.tab_id, self.public_tab_id(ws_idx, tab_idx).unwrap()); + .ok_or(AgentStartError::TargetUnavailable(params.pane_id))?; Ok((agent, argv)) } @@ -214,20 +228,33 @@ impl App { code: "invalid_agent_name".into(), message: "agent name must not be empty".into(), }, - AgentStartError::EmptyArgv => crate::api::schema::ErrorBody { - code: "invalid_agent_argv".into(), - message: "agent start argv must not be empty".into(), + AgentStartError::UnsupportedKind(kind) => crate::api::schema::ErrorBody { + code: "unsupported_agent_kind".into(), + message: format!("unsupported interactive agent kind {kind}"), }, - AgentStartError::TargetNotFound { target } => crate::api::schema::ErrorBody { - code: "agent_placement_not_found".into(), - message: format!("agent placement target {target} not found"), + AgentStartError::InvalidArgument => crate::api::schema::ErrorBody { + code: "invalid_agent_argument".into(), + message: "agent arguments cannot be encoded safely for the target shell".into(), }, - AgentStartError::PlacementConflict => crate::api::schema::ErrorBody { - code: "agent_placement_conflict".into(), - message: "--tab must belong to --workspace".into(), + AgentStartError::InvalidTimeout => crate::api::schema::ErrorBody { + code: "invalid_agent_timeout".into(), + message: "agent start timeout must be greater than 3000ms and at most 300000ms" + .into(), }, - AgentStartError::SpawnFailed(message) => crate::api::schema::ErrorBody { - code: "agent_start_failed".into(), + AgentStartError::TargetNotFound(target) => crate::api::schema::ErrorBody { + code: "agent_pane_not_found".into(), + message: format!("agent target pane {target} not found"), + }, + AgentStartError::TargetBusy(target) => crate::api::schema::ErrorBody { + code: "agent_pane_busy".into(), + message: format!("agent target pane {target} is not an available shell"), + }, + AgentStartError::TargetUnavailable(target) => crate::api::schema::ErrorBody { + code: "agent_pane_unavailable".into(), + message: format!("agent target pane {target} has no live terminal"), + }, + AgentStartError::InputFailed(message) => crate::api::schema::ErrorBody { + code: "agent_start_input_failed".into(), message, }, AgentStartError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody { @@ -291,6 +318,10 @@ impl App { ) -> crate::api::schema::ErrorBody { match err { AgentRenameError::Target(err) => self.agent_target_error_body(err), + AgentRenameError::PendingLaunch => crate::api::schema::ErrorBody { + code: "agent_launch_pending".into(), + message: "agent name cannot change while startup is pending".into(), + }, AgentRenameError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody { code: "agent_name_taken".into(), message: format!( @@ -313,99 +344,7 @@ impl App { } } - fn spawn_agent_workspace( - &mut self, - cwd: PathBuf, - rows: u16, - cols: u16, - argv: &[String], - extra_env: Vec<(String, String)>, - focus: bool, - ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command_with_extra_env( - cwd, - rows, - cols, - argv, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.event_tx.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - extra_env, - ) - .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.terminal_runtimes.insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - self.state.workspaces.push(ws); - let ws_idx = self.state.workspaces.len() - 1; - self.state - .remove_alias_shadowed_by_new_pane(self.state.workspaces[ws_idx].tabs[0].root_pane); - if focus || self.state.active.is_none() { - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - let pane_id = self.state.workspaces[ws_idx].tabs[0].root_pane; - Ok((ws_idx, 0, pane_id)) - } - - fn spawn_agent_split( - &mut self, - ws_idx: usize, - target_pane: crate::layout::PaneId, - split: SplitDirection, - cwd: PathBuf, - argv: &[String], - extra_env: Vec<(String, String)>, - focus: bool, - ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let (rows, cols) = self.state.estimate_pane_size(); - let previous_focus = self.state.current_pane_focus_target(); - let direction = match split { - SplitDirection::Right => ratatui::layout::Direction::Horizontal, - SplitDirection::Down => ratatui::layout::Direction::Vertical, - }; - let result = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| { - ws.split_pane_argv_command( - target_pane, - direction, - rows, - cols, - Some(cwd), - argv, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - focus, - ) - }) - .ok_or_else(|| AgentStartError::TargetNotFound { - target: target_pane.raw().to_string(), - })? - .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.terminal_runtimes - .insert(result.1.terminal.id.clone(), result.1.runtime); - self.state - .remove_alias_shadowed_by_new_pane(result.1.pane_id); - self.state - .terminals - .insert(result.1.terminal.id.clone(), result.1.terminal); - if focus { - self.state.switch_workspace_tab(ws_idx, result.0); - self.state - .record_pane_focus_change(previous_focus, ws_idx, result.1.pane_id); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - Ok((ws_idx, result.0, result.1.pane_id)) - } - - fn agent_info( + pub(super) fn agent_info( &self, ws_idx: usize, pane_id: crate::layout::PaneId, @@ -434,6 +373,9 @@ impl App { tab_id: pane.tab_id, pane_id: pane.pane_id, focused: pane.focused, + launch_pending: terminal.managed_agent_launch_pending(), + interactive_ready: terminal.managed_agent_interactive_ready(), + state_change_seq: terminal.last_agent_state_change_seq.unwrap_or(0), cwd: pane.cwd, foreground_cwd: pane.foreground_cwd, revision: pane.revision, @@ -454,14 +396,45 @@ impl App { } } +fn available_shell_name(runtime: &crate::terminal::TerminalRuntime) -> Option { + #[cfg(test)] + if runtime.child_pid().is_none() { + return Some("sh".into()); + } + crate::platform::available_pane_shell(runtime.child_pid()?) +} + +pub(super) fn runtime_hosts_agent( + runtime: &crate::terminal::TerminalRuntime, + expected: crate::detect::Agent, +) -> bool { + #[cfg(test)] + if runtime.child_pid().is_none() { + return true; + } + live_runtime_agent(runtime) == Some(expected) +} + +fn live_runtime_agent(runtime: &crate::terminal::TerminalRuntime) -> Option { + let job = crate::detect::foreground_job(runtime.child_pid()?)?; + crate::detect::identify_agent_in_job(&job) + .map(|(agent, _)| agent) + .or_else(|| { + job.processes + .iter() + .find_map(|process| crate::platform::process_agent_hint(process.pid)) + }) +} + pub(super) enum AgentStartError { InvalidName, - EmptyArgv, - TargetNotFound { - target: String, - }, - PlacementConflict, - SpawnFailed(String), + UnsupportedKind(String), + InvalidArgument, + InvalidTimeout, + TargetNotFound(String), + TargetBusy(String), + TargetUnavailable(String), + InputFailed(String), DuplicateName { name: String, candidates: Vec, @@ -470,6 +443,7 @@ pub(super) enum AgentStartError { pub(super) enum AgentRenameError { Target(TerminalTargetError), + PendingLaunch, DuplicateName { name: String, candidates: Vec, diff --git a/src/app/api.rs b/src/app/api.rs index 7bc8edf1..79402e47 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -995,6 +995,7 @@ impl App { Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target), 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::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), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index b66924ec..d1fbf14c 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat, - ReadSource, ResponseResult, + AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, + PaneReadResult, ReadFormat, ReadSource, ResponseResult, }; use crate::app::App; @@ -19,6 +19,7 @@ impl App { } pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String { + self.reconcile_managed_agent_target(&target.target); let agent = match self.agent_info_for_target(&target.target) { Ok(agent) => agent, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), @@ -46,11 +47,7 @@ impl App { } pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String { - let extra_env = match super::env::normalize_launch_env(params.env.clone()) { - Ok(env) => env, - Err((code, message)) => return encode_error(id, &code, message), - }; - let (agent, argv) = match self.start_agent(params, extra_env) { + let (agent, argv) = match self.start_agent(params) { Ok(started) => started, Err(err) => return encode_error_body(id, self.agent_start_error_body(err)), }; @@ -58,6 +55,78 @@ impl App { encode_success(id, ResponseResult::AgentStarted { agent, argv }) } + pub(super) fn handle_agent_prompt(&mut self, id: String, params: AgentPromptParams) -> String { + 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) { + 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)) + .cloned() + else { + return agent_not_found(id, ¶ms.target); + }; + 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 { + 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); + }; + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + return encode_error( + id, + "agent_not_ready", + format!( + "agent {} is no longer the pane foreground process", + params.target + ), + ); + } + let bytes = crate::app::api_helpers::encode_api_submission(runtime, ¶ms.text); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "agent_prompt_failed", err.to_string()); + } + 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, + }, + ) + } + pub(super) fn handle_agent_read( &mut self, id: String, @@ -195,6 +264,14 @@ impl App { } } +fn agent_not_ready(id: String, target: &str) -> String { + encode_error( + id, + "agent_not_ready", + format!("agent {target} is not an active named agent"), + ) +} + fn agent_not_found(id: String, target: &str) -> String { encode_error( id, @@ -231,6 +308,100 @@ mod tests { app } + #[tokio::test] + async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() { + 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::OpenCode), AgentState::Idle); + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 1, + ); + runtime.test_process_pty_bytes(b"\x1b[?2004h"); + app.state.insert_test_runtime(pane_id, runtime); + + let response = app.handle_agent_prompt( + "req".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::AgentPrompted { agent, .. } = success.result else { + panic!("expected prompted response"); + }; + assert_eq!(agent.name.as_deref(), Some("reviewer")); + assert_eq!( + rx.try_recv().unwrap(), + Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r") + ); + assert!(rx.try_recv().is_err()); + + app.lookup_runtime_sender(0, pane_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2004l"); + let raw = app.handle_agent_prompt( + "req-raw".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let raw: SuccessResponse = serde_json::from_str(&raw).unwrap(); + assert!(matches!(raw.result, ResponseResult::AgentPrompted { .. })); + assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"A != B\r")); + assert!(rx.try_recv().is_err()); + + let rejected = app.handle_agent_prompt( + "req-label".into(), + AgentPromptParams { + target: "opencode".into(), + text: "wrong target".into(), + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); + assert_eq!(error.error.code, "agent_not_ready"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn agent_prompt_rejects_managed_agent_while_startup_is_pending() { + 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(); + let now = std::time::Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::OpenCode, + now, + std::time::Duration::from_secs(3), + std::time::Duration::from_secs(10), + ); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + let response = app.handle_agent_prompt( + "req-pending".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "agent_not_ready"); + assert!(rx.try_recv().is_err()); + } + #[test] fn agent_focus_marks_already_focused_done_agent_seen() { let mut app = app_with_agent(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 17569682..6a5b41fe 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -20,9 +20,8 @@ use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source, - normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, - MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, + detect_state_from_api, encode_api_keys, normalize_metadata_source, normalize_metadata_tokens, + normalize_metadata_ttl, normalize_reported_agent_label, MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; #[cfg(test)] use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; @@ -1499,20 +1498,16 @@ impl App { let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; - let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { - Ok(encoded_keys) => encoded_keys, + let bytes = match super::super::api_helpers::encode_api_input( + runtime, + ¶ms.text, + ¶ms.keys, + ) { + Ok(bytes) => bytes, Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), }; - if !params.text.is_empty() { - let text_bytes = encode_api_text(runtime, ¶ms.text); - if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { - return encode_error(id, "pane_send_failed", err.to_string()); - } - } - for bytes in encoded_keys { - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - return encode_error(id, "pane_send_failed", err.to_string()); - } + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) @@ -2071,6 +2066,32 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn api_pane_send_input_brackets_text_and_enter_atomically() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); + let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.lookup_runtime_sender(0, internal_pane_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2004h"); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { + pane_id, + text: "A != B".into(), + keys: vec!["Enter".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r") + ); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn api_pane_send_input_keys_accept_key_combo_chords() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c0a98357..c7dc39d2 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -48,6 +48,35 @@ pub(super) fn encode_api_keys( Ok(encoded_keys) } +pub(super) fn encode_api_submission( + runtime: &crate::terminal::TerminalRuntime, + text: &str, +) -> Vec { + let mut bytes = encode_api_text(runtime, text); + let enter = crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ); + bytes.extend_from_slice(&runtime.encode_terminal_key(enter.into())); + bytes +} + +pub(super) fn encode_api_input( + runtime: &crate::terminal::TerminalRuntime, + text: &str, + keys: &[String], +) -> Result, String> { + let mut bytes = if text.is_empty() { + Vec::new() + } else { + encode_api_text(runtime, text) + }; + for encoded in encode_api_keys(runtime, keys)? { + bytes.extend_from_slice(&encoded); + } + Ok(bytes) +} + pub(super) fn detect_state_from_api( state: crate::api::schema::PaneAgentState, ) -> crate::detect::AgentState { diff --git a/src/app/mod.rs b/src/app/mod.rs index 578ea0cd..b6f2bbb8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4146,42 +4146,103 @@ mod tests { } #[tokio::test] - async fn focused_agent_start_records_previous_pane() { + async fn unavailable_agent_start_does_not_mutate_topology() { let mut app = test_app(); - let workspace = Workspace::test_new("agent-start-focus"); + let workspace = Workspace::test_new("agent-start-target"); let root = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; + let pane_id = app.pane_info(0, root).unwrap().pane_id; let response = app.handle_api_request(crate::api::schema::Request { - id: "req_agent_start_focus".into(), + id: "req_agent_start_target".into(), method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams { name: "worker".into(), - cwd: None, - workspace_id: None, - tab_id: None, - split: Some(crate::api::schema::SplitDirection::Right), - focus: true, - argv: vec![exiting_test_command().into()], - env: Default::default(), + kind: "pi".into(), + pane_id, + args: Vec::new(), + timeout_ms: Some(1_000), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(response["result"]["type"], "agent_started"); - assert_ne!(app.state.workspaces[0].focused_pane_id(), Some(root)); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); + assert_eq!(response["error"]["code"], "agent_pane_unavailable"); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + } - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } + #[tokio::test] + async fn failed_agent_start_input_rolls_back_and_can_retry() { + let mut app = test_app(); + let workspace = Workspace::test_new("agent-start-input-failure"); + let root = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.pane_info(0, root).unwrap().pane_id; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_manual_label("shell".into()); + let (runtime, mut receiver) = + crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1); + runtime + .try_send_bytes(bytes::Bytes::from_static(b"occupied")) + .unwrap(); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + let request = || crate::api::schema::Request { + id: "req_agent_start_input".into(), + method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams { + name: "worker".into(), + kind: "pi".into(), + pane_id: pane_id.clone(), + args: Vec::new(), + timeout_ms: Some(4_000), + }), + }; + let response = app.handle_api_request(request()); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "agent_start_input_failed"); + assert_eq!(app.state.terminals[&terminal_id].agent_name, None); + assert_eq!( + app.state.terminals[&terminal_id].manual_label.as_deref(), + Some("shell") + ); + + assert_eq!( + receiver.try_recv().unwrap(), + bytes::Bytes::from_static(b"occupied") + ); + let retry = app.handle_api_request(request()); + let retry: serde_json::Value = serde_json::from_str(&retry).unwrap(); + assert_eq!(retry["result"]["type"], "agent_started"); + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("worker") + ); + let rename = app.handle_api_request(crate::api::schema::Request { + id: "req_agent_rename_pending".into(), + method: crate::api::schema::Method::AgentRename( + crate::api::schema::AgentRenameParams { + target: pane_id, + name: Some("replacement".into()), + }, + ), + }); + let rename: serde_json::Value = serde_json::from_str(&rename).unwrap(); + assert_eq!(rename["error"]["code"], "agent_launch_pending"); + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("worker") + ); } #[test] diff --git a/src/app/runtime.rs b/src/app/runtime.rs index cad49372..97edc471 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -260,6 +260,21 @@ impl App { } } + if self + .state + .next_managed_agent_deadline() + .is_some_and(|deadline| now >= deadline) + { + let panes = self.state.reconcile_managed_agents_at(now); + if !panes.is_empty() { + for (ws_idx, pane_id) in panes { + self.emit_pane_updated(ws_idx, pane_id); + } + self.schedule_session_save(); + changed = true; + } + } + if self .copy_feedback_deadline .is_some_and(|deadline| now >= deadline) @@ -593,6 +608,7 @@ impl App { self.config_diagnostic_deadline, self.toast_deadline, self.state.next_pending_agent_notification_deadline(), + self.state.next_managed_agent_deadline(), self.copy_feedback_deadline, self.next_animation_tick, include_git_refresh diff --git a/src/cli.rs b/src/cli.rs index e59be830..ecca934e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; @@ -932,42 +932,6 @@ fn wait_for_agent_status_change( } } -pub(super) fn wait_for_agent_change( - request: Request, - timeout_ms: Option, - timeout_message: &str, -) -> std::io::Result { - let read_timeout = timeout_ms.map(Duration::from_millis); - let client = ApiClient::local(); - ensure_server_protocol_compatible(&client, &request.id)?; - let (ack, mut stream) = client - .subscribe_value(&request, read_timeout) - .map_err(api_client_error_to_io)?; - if let Err(err) = crate::api::client::parse_response_value(ack) { - if let ApiClientError::ErrorResponse(response) = err { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); - } - return Err(api_client_error_to_io(err)); - } - - match stream.next_event() { - Ok(None) => { - eprintln!("subscription closed before event arrived"); - Ok(1) - } - Ok(Some(event_value)) => { - println!("{}", serde_json::to_string(&event_value).unwrap()); - Ok(0) - } - Err(ApiClientError::Io(err)) if api_timeout_error(&err) => { - eprintln!("{timeout_message}"); - Ok(1) - } - Err(err) => Err(api_client_error_to_io(err)), - } -} - pub(super) fn print_response(response: &serde_json::Value) -> std::io::Result { if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(response).unwrap()); @@ -1030,13 +994,6 @@ pub(crate) fn protocol_mismatch_was_reported(err: &std::io::Error) -> bool { protocol_guard::was_reported(err) } -fn api_timeout_error(err: &std::io::Error) -> bool { - matches!( - err.kind(), - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock - ) -} - fn api_client_error_to_io(err: ApiClientError) -> std::io::Error { match err { ApiClientError::Io(err) => err, diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 80148046..898de33c 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -1,8 +1,8 @@ -use std::collections::HashMap; +use std::time::{Duration, Instant}; use crate::api::schema::{ - AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus, - AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, Subscription, + AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, + AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, }; pub(super) fn run_agent_command(args: &[String]) -> std::io::Result { @@ -16,6 +16,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result { "get" => agent_get(&args[1..]), "read" => agent_read(&args[1..]), "send" => agent_send(&args[1..]), + "prompt" => agent_prompt(&args[1..]), "rename" => agent_rename(&args[1..]), "focus" => agent_focus(&args[1..]), "wait" => agent_wait(&args[1..]), @@ -269,82 +270,41 @@ fn matched_rule_region_preview<'a>( fn agent_start(args: &[String]) -> std::io::Result { let Some(name) = args.first() else { - eprintln!("usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- "); + eprintln!("usage: herdr agent start --kind KIND --pane ID [--timeout MS] [-- ]"); return Ok(2); }; - - let Some(separator) = args.iter().position(|arg| arg == "--") else { - eprintln!("usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- "); - return Ok(2); - }; - if separator == args.len() - 1 { - eprintln!("agent start requires argv after --"); - return Ok(2); - } - - let mut cwd = None; - let mut workspace_id = None; - let mut tab_id = None; - let mut split = None; - let mut focus = false; - let mut env = HashMap::new(); - + let separator = args + .iter() + .position(|arg| arg == "--") + .unwrap_or(args.len()); + let mut kind = None; + let mut pane_id = None; + let mut timeout_ms = None; let mut index = 1; while index < separator { match args[index].as_str() { - "--cwd" => { + "--kind" => { let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --cwd"); + eprintln!("missing value for --kind"); return Ok(2); }; - cwd = Some(value.clone()); + kind = Some(value.clone()); index += 2; } - "--workspace" => { + "--pane" => { let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --workspace"); + eprintln!("missing value for --pane"); return Ok(2); }; - workspace_id = Some(super::normalize_workspace_id(value)); + pane_id = Some(super::normalize_pane_id(value)); index += 2; } - "--tab" => { + "--timeout" => { let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --tab"); + eprintln!("missing value for --timeout"); return Ok(2); }; - tab_id = Some(super::normalize_tab_id(value)); - index += 2; - } - "--split" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --split"); - return Ok(2); - }; - split = Some(super::parse_split_direction(value)?); - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - "--env" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --env"); - return Ok(2); - }; - let (key, value) = match super::parse_env_assignment(value) { - Ok(pair) => pair, - Err(err) => { - eprintln!("{err}"); - return Ok(2); - } - }; - env.insert(key, value); + timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); index += 2; } other => { @@ -353,20 +313,63 @@ fn agent_start(args: &[String]) -> std::io::Result { } } } - - super::print_response(&super::send_request(&Request { + let Some(kind) = kind else { + eprintln!("missing required --kind"); + return Ok(2); + }; + let Some(pane_id) = pane_id else { + eprintln!("missing required --pane"); + return Ok(2); + }; + let Some(expected_kind) = crate::detect::parse_agent_label(&kind) else { + eprintln!("unsupported interactive agent kind: {kind}"); + return Ok(2); + }; + let expected_kind = crate::detect::agent_label(expected_kind).to_string(); + let mut response = super::send_request(&Request { id: "cli:agent:start".into(), method: Method::AgentStart(AgentStartParams { name: name.clone(), - cwd, - workspace_id, - tab_id, - split, - focus, - argv: args[separator + 1..].to_vec(), - env, + kind, + pane_id: pane_id.clone(), + args: if separator < args.len() { + args[separator + 1..].to_vec() + } else { + Vec::new() + }, + timeout_ms, }), - })?) + })?; + if response.get("error").is_some() { + return super::print_response(&response); + } + let Some(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, + }, + ); + match waited { + Ok(Ok(agent)) => { + response["result"]["agent"] = agent; + super::print_response(&response) + } + Ok(Err(error)) => super::print_response(&error), + Err(err) => { + print_agent_transport_error(err, "cli:agent:start", "agent_start_transport_failed") + } + } } fn agent_list(args: &[String]) -> std::io::Result { @@ -439,24 +442,13 @@ fn agent_attach(args: &[String]) -> std::io::Result { fn agent_wait(args: &[String]) -> std::io::Result { let Some(target) = args.first() else { - eprintln!("usage: herdr agent wait --status [--timeout MS]"); + eprintln!("usage: herdr agent wait [--timeout MS]"); return Ok(2); }; - 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_wait_status(value)?); - index += 2; - } "--timeout" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --timeout"); @@ -466,7 +458,7 @@ fn agent_wait(args: &[String]) -> std::io::Result { index += 2; } "help" | "--help" | "-h" => { - eprintln!("usage: herdr agent wait --status [--timeout MS]"); + eprintln!("usage: herdr agent wait [--timeout MS]"); return Ok(0); } other => { @@ -475,67 +467,234 @@ fn agent_wait(args: &[String]) -> std::io::Result { } } } - - let Some(agent_status) = desired_status else { - eprintln!("missing required --status"); - return Ok(2); - }; - - let response = resolve_agent_target(target, "cli:agent:wait:resolve")?; - if response.get("error").is_some() { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); + let initial = resolve_agent_target(target, "cli:agent:wait:resolve")?; + if initial.get("error").is_some() { + return super::print_response(&initial); } - if response["result"]["agent"]["agent_status"] - .as_str() - .is_some_and(|current| agent_wait_status_satisfied(agent_status, current)) - { - println!("{}", serde_json::to_string(&response).unwrap()); - return Ok(0); + 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"), + )); } - - let Some(pane_id) = response["result"]["agent"]["pane_id"].as_str() else { - eprintln!("agent wait failed: response did not include pane_id"); - return Ok(1); + 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 subscriptions = if agent_status == AgentStatus::Idle { - vec![ - Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(AgentStatus::Idle), - }, - Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(AgentStatus::Done), - }, - ] - } else { - vec![Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(agent_status), - }] + 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), + } +} - super::wait_for_agent_change( - Request { - id: "cli:agent:wait".into(), - method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams { - subscriptions, - }), - }, - timeout_ms, - "timed out waiting for agent status change", +#[derive(Clone, Copy)] +enum AgentWaitMode<'a> { + Start { expected_kind: &'a str }, + Current, + AfterPrompt { baseline_state_change_seq: u64 }, +} + +fn wait_for_named_agent( + lookup_target: &str, + expected_name: &str, + timeout: Option, + mode: AgentWaitMode<'_>, +) -> std::io::Result> { + let started_at = Instant::now(); + let deadline = timeout.and_then(|timeout| 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 poll_id = match mode { + AgentWaitMode::Start { .. } => "cli:agent:start", + AgentWaitMode::Current => "cli:agent:wait", + AgentWaitMode::AfterPrompt { .. } => "cli:agent:prompt", + }; + let response = if first_poll { + first_poll = false; + resolve_agent_target(lookup_target, poll_id)? + } else { + resolve_agent_target_unchecked(lookup_target, 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", + ))); + } + 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) + { + 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 + } + } + }; + if let Some(outcome) = outcome { + return Ok(outcome); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn agent_name_lost_error(request_id: &str, expected_name: &str) -> serde_json::Value { + cli_agent_error( + request_id, + "agent_name_not_found", + format!("named agent {expected_name} no longer owns the target terminal"), ) } +fn print_agent_transport_error( + err: std::io::Error, + request_id: &str, + code: &str, +) -> std::io::Result { + if super::protocol_mismatch_was_reported(&err) { + return Ok(1); + } + 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 cli_agent_error(id: &str, code: &str, message: impl Into) -> serde_json::Value { + serde_json::json!({ + "id": id, + "error": { "code": code, "message": message.into() } + }) +} + fn resolve_agent_target(target: &str, request_id: &str) -> std::io::Result { - super::send_request(&Request { + super::send_request(&agent_get_request(target, request_id)) +} + +fn resolve_agent_target_unchecked( + target: &str, + request_id: &str, +) -> std::io::Result { + super::send_request_unchecked(&agent_get_request(target, request_id)) +} + +fn agent_get_request(target: &str, request_id: &str) -> Request { + Request { id: request_id.into(), method: Method::AgentGet(AgentTarget { target: target.to_owned(), }), - }) + } } fn agent_rename(args: &[String]) -> std::io::Result { @@ -562,6 +721,84 @@ fn agent_rename(args: &[String]) -> std::io::Result { })?) } +fn agent_prompt(args: &[String]) -> std::io::Result { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent prompt [--wait] [--timeout MS]"); + return Ok(2); + }; + let Some(text) = args.get(1) else { + eprintln!("agent prompt requires text"); + return Ok(2); + }; + let mut wait = false; + let mut timeout_ms = None; + let mut index = 2; + while index < args.len() { + match args[index].as_str() { + "--wait" => { + wait = true; + index += 1; + } + "--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; + } + option => { + eprintln!("unknown option: {option}"); + return Ok(2); + } + } + } + let mut response = super::send_request(&Request { + id: "cli:agent:prompt".into(), + method: Method::AgentPrompt(AgentPromptParams { + target: target.clone(), + text: text.clone(), + }), + })?; + 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), + } +} + fn agent_send(args: &[String]) -> std::io::Result { if args.len() < 2 { eprintln!("usage: herdr agent send "); @@ -640,42 +877,20 @@ fn agent_read(args: &[String]) -> std::io::Result { })?) } -fn agent_wait_status_satisfied(desired: AgentStatus, current: &str) -> bool { - match desired { - AgentStatus::Idle => matches!(current, "idle" | "done"), - AgentStatus::Working => current == "working", - AgentStatus::Blocked => current == "blocked", - AgentStatus::Unknown => current == "unknown", - AgentStatus::Done => false, - } -} - -fn parse_agent_wait_status(value: &str) -> std::io::Result { - match value { - "idle" => Ok(AgentStatus::Idle), - "working" => Ok(AgentStatus::Working), - "blocked" => Ok(AgentStatus::Blocked), - "unknown" => Ok(AgentStatus::Unknown), - "done" => Err(std::io::Error::other( - "done is a UI attention state; use idle for CLI agent completion waits", - )), - _ => Err(std::io::Error::other(format!( - "invalid agent status: {value} (expected idle, working, blocked, or unknown)" - ))), - } -} - fn print_agent_help() { eprintln!("herdr agent commands:"); eprintln!(" herdr agent list"); eprintln!(" herdr agent get "); eprintln!(" herdr agent read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); eprintln!(" herdr agent send "); + eprintln!(" herdr agent prompt [--wait] [--timeout MS]"); eprintln!(" herdr agent rename |--clear"); eprintln!(" herdr agent focus "); - eprintln!(" herdr agent wait --status [--timeout MS]"); + eprintln!(" herdr agent wait [--timeout MS]"); eprintln!(" herdr agent attach [--takeover]"); - eprintln!(" herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- "); + eprintln!( + " herdr agent start --kind KIND --pane ID [--timeout MS] [-- ]" + ); eprintln!(" herdr agent explain [--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"); diff --git a/src/cli/spec.rs b/src/cli/spec.rs index f27eed49..31cd834f 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -284,6 +284,14 @@ fn agent_command() -> Command { .arg(required("target", "TARGET")) .arg(required("text", "TEXT")), ) + .subcommand( + Command::new("prompt") + .about("Submit a prompt to a named agent") + .arg(required("name", "NAME")) + .arg(required("text", "TEXT")) + .arg(flag("wait")) + .arg(option("timeout", "MS")), + ) .subcommand( Command::new("rename") .about("Rename an agent") @@ -294,9 +302,8 @@ fn agent_command() -> Command { .subcommand(id_command("focus", "target", "Focus an agent")) .subcommand( Command::new("wait") - .about("Wait for an agent status") - .arg(required("target", "TARGET")) - .arg(agent_wait_status_option()) + .about("Wait until a named agent is no longer working") + .arg(required("name", "NAME")) .arg(option("timeout", "MS")), ) .subcommand( @@ -307,15 +314,17 @@ fn agent_command() -> Command { ) .subcommand( Command::new("start") - .about("Start an agent command") + .about("Start a supported interactive agent in an existing pane") .arg(required("name", "NAME")) - .arg(path_option("cwd", "PATH")) - .arg(option("workspace", "ID")) - .arg(option("tab", "ID")) - .arg(split_option()) - .arg(env_option()) - .arg(flag("focus")) - .arg(flag("no-focus")), + .arg(option("kind", "KIND").required(true)) + .arg(option("pane", "ID").required(true)) + .arg(option("timeout", "MS")) + .arg( + Arg::new("agent_args") + .value_name("AGENT_ARG") + .num_args(0..) + .last(true), + ), ) .subcommand( Command::new("explain") @@ -754,10 +763,6 @@ fn direction_option() -> Arg { option("direction", "DIRECTION").value_parser(["left", "right", "up", "down"]) } -fn split_option() -> Arg { - option("split", "DIRECTION").value_parser(["right", "down"]) -} - fn split_direction_option() -> Arg { option("direction", "DIRECTION").value_parser(["right", "down"]) } @@ -768,12 +773,6 @@ fn status_option(name: &'static str, required: bool) -> Arg { .value_parser(["idle", "working", "blocked", "done", "unknown"]) } -fn agent_wait_status_option() -> Arg { - option("status", "STATUS") - .required(true) - .value_parser(["idle", "working", "blocked", "unknown"]) -} - fn pane_agent_state_option(name: &'static str) -> Arg { option(name, "STATUS") .required(true) @@ -927,14 +926,11 @@ mod tests { } #[test] - fn spec_includes_agent_status_values() { + fn spec_keeps_agent_wait_status_free() { let cmd = super::command(); let wait = command_path(&cmd, &["agent", "wait"]); - let values = option_values(wait, "status"); - assert!(values.contains(&"idle".to_string())); - assert!(values.contains(&"working".to_string())); - assert!(values.contains(&"blocked".to_string())); - assert!(!values.contains(&"done".to_string())); + assert!(!has_option(wait, "status")); + assert!(has_option(wait, "timeout")); } #[test] @@ -954,12 +950,17 @@ mod tests { } #[test] - fn spec_does_not_complete_agent_start_argv_without_separator() { + fn spec_models_agent_start_target_and_trailing_args() { let cmd = super::command(); let agent_start = command_path(&cmd, &["agent", "start"]); - assert!(!agent_start + assert!(has_option(agent_start, "kind")); + 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}"); + } + assert!(agent_start .get_arguments() - .any(|arg| arg.get_id() == "argv")); + .any(|arg| arg.get_id() == "agent_args")); } #[test] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 5c220b52..53c3213a 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -65,6 +65,31 @@ pub enum Agent { } impl Agent { + #[cfg(test)] + pub const ALL: [Self; 21] = [ + Self::Pi, + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Cursor, + Self::Devin, + Self::Antigravity, + Self::Cline, + Self::Omp, + Self::Mastracode, + Self::OpenCode, + Self::GithubCopilot, + Self::Kimi, + Self::Kiro, + Self::Droid, + Self::Amp, + Self::Grok, + Self::Hermes, + Self::Kilo, + Self::Qodercli, + Self::Maki, + ]; + pub const SCREEN_MANIFEST_AGENTS: [Self; 19] = [ Self::Pi, Self::Claude, @@ -114,6 +139,32 @@ pub fn agent_label(agent: Agent) -> &'static str { } } +pub fn interactive_agent_executable(agent: Agent) -> &'static str { + match agent { + Agent::Pi => "pi", + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Gemini => "gemini", + Agent::Cursor => "cursor-agent", + Agent::Devin => "devin", + Agent::Antigravity => "agy", + Agent::Cline => "cline", + Agent::Omp => "omp", + Agent::Mastracode => "mastracode", + Agent::OpenCode => "opencode", + Agent::GithubCopilot => "copilot", + Agent::Kimi => "kimi", + Agent::Kiro => "kiro-cli", + Agent::Droid => "droid", + Agent::Amp => "amp", + Agent::Grok => "grok", + Agent::Hermes => "hermes", + Agent::Kilo => "kilo", + Agent::Qodercli => "qodercli", + Agent::Maki => "maki", + } +} + pub fn parse_agent_label(agent: &str) -> Option { let name = normalized_agent_lookup_name(agent); parse_canonical_agent_label(&name).or_else(|| lookup_agent(&name)) @@ -662,37 +713,44 @@ mod tests { #[test] fn every_agent_label_round_trips_through_canonical_and_alias_parsers() { - let agents = [ - Agent::Pi, - Agent::Claude, - Agent::Codex, - Agent::Gemini, - Agent::Cursor, - Agent::Devin, - Agent::Antigravity, - Agent::Cline, - Agent::Omp, - Agent::Mastracode, - Agent::OpenCode, - Agent::GithubCopilot, - Agent::Kimi, - Agent::Kiro, - Agent::Droid, - Agent::Amp, - Agent::Grok, - Agent::Hermes, - Agent::Kilo, - Agent::Qodercli, - Agent::Maki, - ]; - - for agent in agents { + for agent in Agent::ALL { let label = agent_label(agent); assert_eq!(parse_canonical_agent_label(label), Some(agent)); assert_eq!(parse_agent_label(label), Some(agent)); } } + #[test] + fn every_agent_has_a_canonical_interactive_executable() { + let expected = [ + (Agent::Pi, "pi"), + (Agent::Claude, "claude"), + (Agent::Codex, "codex"), + (Agent::Gemini, "gemini"), + (Agent::Cursor, "cursor-agent"), + (Agent::Devin, "devin"), + (Agent::Antigravity, "agy"), + (Agent::Cline, "cline"), + (Agent::Omp, "omp"), + (Agent::Mastracode, "mastracode"), + (Agent::OpenCode, "opencode"), + (Agent::GithubCopilot, "copilot"), + (Agent::Kimi, "kimi"), + (Agent::Kiro, "kiro-cli"), + (Agent::Droid, "droid"), + (Agent::Amp, "amp"), + (Agent::Grok, "grok"), + (Agent::Hermes, "hermes"), + (Agent::Kilo, "kilo"), + (Agent::Qodercli, "qodercli"), + (Agent::Maki, "maki"), + ]; + assert_eq!(expected.len(), Agent::ALL.len()); + for (agent, executable) in expected { + assert_eq!(interactive_agent_executable(agent), executable); + } + } + #[test] fn canonical_agent_labels_are_strict() { assert_eq!(parse_canonical_agent_label("claude-code"), None); diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs index e5121a88..29ba85f4 100644 --- a/src/handoff_runtime.rs +++ b/src/handoff_runtime.rs @@ -1,6 +1,13 @@ #[cfg(unix)] use serde::{Deserialize, Serialize}; +/// Long-lived pane runtime transferred during server replacement. +/// +/// Handoff preserves server-owned session state such as PTYs, processes, agent +/// identity, and durable plugin/session metadata. It intentionally does not +/// preserve transient coordination such as in-flight requests, waits, +/// subscriptions, client sockets, or pane-to-pane messages; clients reconnect +/// and retry those operations after replacement. #[cfg(unix)] #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct HandoffRuntimeState { diff --git a/src/persist/restore.rs b/src/persist/restore.rs index bd4d8382..2197e2bb 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -487,6 +487,9 @@ fn restore_tab( let saved_label = saved_pane.and_then(|p| p.label.clone()); let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone()); + let saved_managed_agent = saved_pane + .and_then(|pane| pane.managed_agent_kind.as_deref()) + .and_then(crate::detect::parse_canonical_agent_label); let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone()); let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); let saved_history = @@ -530,8 +533,12 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } - if let Some(agent_name) = saved_agent_name { - terminal.set_agent_name(agent_name); + 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), + (None, _) => {} } if let Some(agent) = initial_restore_agent { let _ = terminal.set_detected_state_with_screen_signals_at( @@ -623,8 +630,13 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } - if let Some(agent_name) = saved_agent_name { - terminal.set_agent_name(agent_name); + 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), + (None, _) => {} } if let Some(agent) = initial_restore_agent { let _ = terminal.set_detected_state_with_screen_signals_at( @@ -1171,8 +1183,9 @@ mod tests { 0, super::super::snapshot::PaneSnapshot { cwd, - label: None, - agent_name: None, + label: Some("reviewer".into()), + agent_name: Some("reviewer".into()), + managed_agent_kind: Some("opencode".into()), agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:opencode".into(), agent: "opencode".into(), @@ -1218,6 +1231,8 @@ mod tests { !terminal.respawn_shell_on_exit, "agent sessions should not use native restore lifecycle when resume_agents_on_restore is disabled" ); + assert_eq!(terminal.agent_name, None); + assert_eq!(terminal.manual_label.as_deref(), Some("reviewer")); let session = terminal .persisted_agent_session .as_ref() @@ -1256,6 +1271,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1266,6 +1282,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1318,6 +1335,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1327,6 +1345,7 @@ mod tests { cwd: cwd.clone(), label: Some("planner".into()), agent_name: Some("planner".into()), + managed_agent_kind: None, agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:codex".into(), agent: "codex".into(), @@ -1416,6 +1435,8 @@ mod tests { assert_eq!(workspace.tabs[3].number, 5); assert_eq!(detail.tab_idx, 3); assert_eq!(detail.agent_label, "planner"); + let terminal_id = &workspace.tabs[3].panes[&agent_pane].attached_terminal_id; + assert_eq!(terminals[terminal_id].managed_agent_kind(), None); } #[test] @@ -1478,6 +1499,7 @@ mod tests { cwd, label: None, agent_name: None, + managed_agent_kind: None, agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:codex".into(), agent: "codex".into(), @@ -1643,6 +1665,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index f78fd836..e1e112a0 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -102,6 +102,8 @@ pub struct PaneSnapshot { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_agent_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_argv: Option>, @@ -319,51 +321,51 @@ fn capture_tab( let cwd = tab .cwd_for_pane(*id, terminals, terminal_runtimes) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let label = tab + let terminal = tab .panes .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.manual_label.clone()); - let agent_name = tab - .panes - .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.agent_name.clone()); - let launch_argv = tab - .panes - .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.launch_argv.clone()); - let agent_session = - tab.panes - .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| { - if let Some(authority) = terminal.hook_authority.as_ref() { - if let Some(session_ref) = authority.session_ref.as_ref() { - return Some(PaneAgentSessionSnapshot { - source: authority.source.clone(), - agent: authority.agent_label.clone(), - kind: session_ref.kind, - value: session_ref.value.clone(), - }); - } - } - terminal.persisted_agent_session.as_ref().map(|session| { - PaneAgentSessionSnapshot { - source: session.source.clone(), - agent: session.agent.clone(), - kind: session.session_ref.kind, - value: session.session_ref.value.clone(), - } - }) - }); + .and_then(|pane| terminals.get(&pane.attached_terminal_id)); + let label = terminal.and_then(|terminal| terminal.manual_label.clone()); + let (agent_name, managed_agent_kind) = terminal + .filter(|terminal| !terminal.managed_agent_launch_pending()) + .map(|terminal| { + ( + terminal.agent_name.clone(), + terminal + .managed_agent_kind() + .map(|agent| crate::detect::agent_label(agent).to_string()), + ) + }) + .unwrap_or_default(); + let launch_argv = terminal.and_then(|terminal| terminal.launch_argv.clone()); + let agent_session = terminal.and_then(|terminal| { + if let Some(authority) = terminal.hook_authority.as_ref() { + if let Some(session_ref) = authority.session_ref.as_ref() { + return Some(PaneAgentSessionSnapshot { + source: authority.source.clone(), + agent: authority.agent_label.clone(), + kind: session_ref.kind, + value: session_ref.value.clone(), + }); + } + } + terminal + .persisted_agent_session + .as_ref() + .map(|session| PaneAgentSessionSnapshot { + source: session.source.clone(), + agent: session.agent.clone(), + kind: session.session_ref.kind, + value: session.session_ref.value.clone(), + }) + }); panes.insert( id.raw(), PaneSnapshot { cwd, label, agent_name, + managed_agent_kind, agent_session, launch_argv, }, @@ -556,6 +558,43 @@ mod tests { } } + #[test] + fn managed_agent_snapshot_omits_pending_and_persists_active_ownership() { + let mut state = state_with_workspaces(&["managed-snapshot"]); + let root = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + let now = std::time::Instant::now(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .begin_managed_agent( + "reviewer".into(), + crate::detect::Agent::Pi, + now, + std::time::Duration::ZERO, + std::time::Duration::from_secs(1), + ); + + let pending = capture_from_state(&state); + let pending_pane = &pending.workspaces[0].tabs[0].panes[&root.raw()]; + assert_eq!(pending_pane.agent_name, None); + assert_eq!(pending_pane.managed_agent_kind, None); + + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + assert!(terminal.reconcile_managed_agent_at(now, false)); + let active = capture_from_state(&state); + let active_pane = &active.workspaces[0].tabs[0].panes[&root.raw()]; + assert_eq!(active_pane.agent_name.as_deref(), Some("reviewer")); + assert_eq!(active_pane.managed_agent_kind.as_deref(), Some("pi")); + } + #[test] fn round_trip_empty_session() { let snap = SessionSnapshot { @@ -606,6 +645,7 @@ mod tests { cwd: PathBuf::from("/home/can/Projects/herdr"), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -616,6 +656,7 @@ mod tests { cwd: PathBuf::from("/home/can/Projects/website"), label: Some("website".into()), agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1157,6 +1198,7 @@ mod tests { cwd: PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test"), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1169,6 +1211,7 @@ mod tests { .unwrap_or_else(|_| PathBuf::from("/tmp")), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 6b5700cc..30bea5eb 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -27,6 +27,10 @@ pub(crate) fn pane_custom_command_pty_builder_platform( portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) } +pub(crate) fn interactive_shell_command(_argv: &[String], _shell_name: &str) -> Option { + None +} + /// Unsupported platform stub. pub(crate) fn scrollback_editor_argv(_path: &std::path::Path) -> std::io::Result> { Err(std::io::Error::new( @@ -43,6 +47,10 @@ pub fn current_process_is_detached_server_daemon() -> bool { false } +pub(crate) fn available_pane_shell(_child_pid: u32) -> Option { + None +} + /// Unsupported platform stub. pub fn foreground_job(_child_pid: u32) -> Option { None diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 48f1ef1c..6557b41b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -70,6 +70,10 @@ pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result< Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command]) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + super::interactive_unix_shell_command(argv, shell_name, shell_quote) +} + fn shell_quote(value: &str) -> String { if !value.is_empty() && value.chars().all(|ch| { @@ -87,6 +91,10 @@ fn shell_quote(value: &str) -> String { } /// Collect the foreground terminal job for a given child PID. +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?) +} + pub fn foreground_job(child_pid: u32) -> Option { let tpgid = foreground_process_group_id(child_pid)?; let members = foreground_process_group_members(child_pid, tpgid)?; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index f35fd1eb..161354d7 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -45,6 +45,10 @@ pub(crate) fn scrollback_editor_argv(path: &Path) -> std::io::Result Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command]) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + super::interactive_unix_shell_command(argv, shell_name, shell_quote) +} + fn shell_quote(value: &str) -> String { if !value.is_empty() && value.chars().all(|ch| { @@ -315,6 +319,10 @@ fn target_nofile_soft_limit( (current < target).then_some(target) } +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?) +} + /// Collect the foreground terminal job for a given child PID. pub fn foreground_job(child_pid: u32) -> Option { if child_pid == 0 { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 82a6704d..1415181e 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -157,6 +157,90 @@ mod fallback; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] pub use fallback::*; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn available_pane_shell_from_job(child_pid: u32, job: ForegroundJob) -> Option { + if job.process_group_id != child_pid + || job.processes.iter().any(|process| process.pid != child_pid) + { + return None; + } + job.processes + .into_iter() + .find(|process| process.pid == child_pid) + .map(|process| process.name) + .filter(|name| is_pane_shell_process_name(name)) +} + +fn normalized_process_name(name: &str) -> String { + name.rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim_start_matches('-') + .trim_end_matches(".exe") + .to_ascii_lowercase() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn is_powershell_process_name(name: &str) -> bool { + matches!( + normalized_process_name(name).as_str(), + "pwsh" | "powershell" + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn interactive_unix_shell_command( + argv: &[String], + shell_name: &str, + quote_posix_arg: fn(&str) -> String, +) -> Option { + let quote = if is_powershell_process_name(shell_name) { + quote_powershell_arg + } else { + quote_posix_arg + }; + let mut parts = argv.iter(); + let mut command = quote(parts.next()?); + for part in parts { + command.push(' '); + command.push_str("e(part)); + } + Some(command) +} + +pub(crate) fn quote_powershell_arg(value: &str) -> String { + if !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'-' | b'.' | b'/' | b':' | b'+' | b'=') + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "''")) +} + +pub(crate) fn is_pane_shell_process_name(name: &str) -> bool { + let normalized = normalized_process_name(name); + matches!( + normalized.as_str(), + "sh" | "bash" + | "dash" + | "zsh" + | "fish" + | "ksh" + | "mksh" + | "csh" + | "tcsh" + | "elvish" + | "xonsh" + | "nu" + | "pwsh" + | "powershell" + | "cmd" + ) +} + #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn process_agent_hint(_pid: u32) -> Option { None @@ -226,6 +310,16 @@ impl PrefixInputSource for RealPrefixInputSource { mod tests { use super::*; + #[test] + fn pane_shell_process_names_reject_exec_replacement_programs() { + for shell in ["bash", "-zsh", "/bin/fish", "pwsh", "powershell.exe"] { + assert!(is_pane_shell_process_name(shell), "{shell}"); + } + for program in ["vim", "nvim", "cargo", "test-runner", "opencode"] { + assert!(!is_pane_shell_process_name(program), "{program}"); + } + } + #[test] fn detached_custom_command_preserves_unix_login_shell_flag() { let cmd = detached_custom_command_process("echo hello"); @@ -269,6 +363,28 @@ mod tests { assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn interactive_shell_command_quotes_for_posix_and_powershell() { + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "a'b".into(), + "$HOME".into(), + "semi;colon".into(), + "@options".into(), + ]; + assert_eq!( + interactive_shell_command(&argv, "bash").as_deref(), + Some("pi '' 'two words' 'a'\\''b' '$HOME' 'semi;colon' @options") + ); + assert_eq!( + interactive_shell_command(&argv, "pwsh").as_deref(), + Some("pi '' 'two words' 'a''b' '$HOME' 'semi;colon' '@options'") + ); + } + #[test] fn read_limited_reader_returns_complete_data_under_limit() { let input = std::io::Cursor::new(b"image".to_vec()); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 24de21ad..ef41e662 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -62,6 +62,71 @@ fn raw_command_shell(comspec: Option) -> std::ffi::OsString .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + let shell_name = shell_name.to_ascii_lowercase(); + let powershell = shell_name.contains("powershell") || shell_name.contains("pwsh"); + let script = powershell_agent_script(argv)?; + if powershell { + Some(script) + } else { + Some(cmd_encoded_powershell_command(&script)) + } +} + +fn powershell_agent_script(argv: &[String]) -> Option { + let (program, args) = argv.split_first()?; + let command_line = args + .iter() + .map(|arg| quote_windows_command_line_arg(arg)) + .collect::>() + .join(" "); + Some(format!( + "$p=Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait -PassThru", + super::quote_powershell_arg(program), + super::quote_powershell_arg(&command_line), + )) +} + +fn quote_windows_command_line_arg(value: &str) -> String { + if !value.is_empty() + && !value + .chars() + .any(|ch| matches!(ch, ' ' | '\t' | '\n' | '\x0b' | '"')) + { + return value.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in value.chars() { + if ch == '\\' { + backslashes += 1; + continue; + } + if ch == '"' { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + } else { + quoted.push_str(&"\\".repeat(backslashes)); + } + backslashes = 0; + quoted.push(ch); + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} + +fn cmd_encoded_powershell_command(script: &str) -> String { + use base64::Engine as _; + + let utf16 = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(utf16); + format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}") +} + pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec")) } @@ -155,6 +220,23 @@ pub fn foreground_job(child_pid: u32) -> Option { select_pane_foreground_job(child_pid, &entries) } +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + available_pane_shell_from_snapshot(child_pid, &snapshot_processes()) +} + +fn available_pane_shell_from_snapshot( + child_pid: u32, + entries: &[WindowsProcessEntry], +) -> Option { + let shell = entries.iter().find(|entry| entry.pid == child_pid)?; + if !super::is_pane_shell_process_name(&shell.name) { + return None; + } + descendant_entries(child_pid, entries) + .is_empty() + .then(|| shell.name.clone()) +} + pub fn foreground_group_leader_job(process_group_id: u32) -> Option { let entries = snapshot_processes(); let entry = entries.iter().find(|entry| entry.pid == process_group_id)?; @@ -670,6 +752,91 @@ mod tests { AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow, }; + #[test] + fn cmd_agent_command_encodes_edge_arguments_without_cmd_expansion() { + use base64::Engine as _; + + assert_eq!(super::super::quote_powershell_arg("@options"), "'@options'"); + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "100%".into(), + "wow!".into(), + "a'b".into(), + ]; + let command = super::interactive_shell_command(&argv, "cmd.exe").unwrap(); + let encoded = command.split_whitespace().last().unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let utf16 = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + assert_eq!( + String::from_utf16(&utf16).unwrap(), + "$p=Start-Process -FilePath pi -ArgumentList '\"\" \"two words\" 100% wow! a''b' -NoNewWindow -Wait -PassThru" + ); + } + + #[test] + fn windows_shells_round_trip_agent_arguments_through_a_real_command() { + let base = std::env::temp_dir().join(format!( + "herdr-agent-argv-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + fs::create_dir_all(&base).unwrap(); + let helper = base.join("pi.cmd"); + fs::write( + &helper, + "@echo off\r\n>\"%HERDR_ARGV_CAPTURE%\" (\r\necho(%~1\r\necho(%~2\r\necho(%~3\r\necho(%~4\r\necho(%~5\r\necho(%~6\r\n)\r\n", + ) + .unwrap(); + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "100%".into(), + "wow!".into(), + "a'b".into(), + "@options".into(), + ]; + let inherited_path = std::env::var_os("PATH").unwrap_or_default(); + let path = format!("{};{}", base.display(), inherited_path.to_string_lossy()); + + for shell in ["powershell.exe", "cmd.exe"] { + let capture = base.join(format!("{shell}.txt")); + let command = super::interactive_shell_command(&argv, shell).unwrap(); + let status = if shell == "cmd.exe" { + Command::new("cmd.exe") + .args(["/d", "/c", &command]) + .env("PATH", &path) + .env("HERDR_ARGV_CAPTURE", &capture) + .status() + .unwrap() + } else { + Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-Command", &command]) + .env("PATH", &path) + .env("HERDR_ARGV_CAPTURE", &capture) + .status() + .unwrap() + }; + assert!(status.success(), "{shell} command failed"); + assert_eq!( + fs::read_to_string(capture).unwrap().replace("\r\n", "\n"), + "\ntwo words\n100%\nwow!\na'b\n@options\n" + ); + } + + let _ = fs::remove_dir_all(base); + } + const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE"; const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID"; @@ -1001,6 +1168,27 @@ mod tests { assert_eq!(job.processes[0].name, "powershell.exe"); } + #[test] + fn windows_shell_is_available_only_without_descendants() { + let shell_only = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])]; + assert_eq!( + super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(), + Some("powershell.exe") + ); + + let busy = vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "git.exe", &["git.exe", "status"]), + ]; + assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None); + + let replaced = vec![test_entry(10, 1, "vim.exe", &["vim.exe"])]; + assert_eq!( + super::available_pane_shell_from_snapshot(10, &replaced), + None + ); + } + #[test] fn windows_process_tree_returns_shell_for_multiple_agent_descendants() { let entries = vec![ diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index e31f8ff5..9cc2924b 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 16; +pub const PROTOCOL_VERSION: u32 = 17; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 632558fd..815a8b80 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; -#[cfg(any(windows, test))] -use std::time::Duration; -use std::time::Instant; +use std::time::{Duration, Instant}; // Effective state arbitration is intentionally centralized here. Full lifecycle // Herdr hook integrations are hook-authoritative while live; screen recovery @@ -46,6 +44,22 @@ struct StaleFullLifecycleHookSession { session_ref: crate::agent_resume::AgentSessionRef, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManagedAgentPhase { + Pending { + ready_after: Option, + deadline: Instant, + }, + Active, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ManagedAgent { + kind: Agent, + observed_expected: bool, + phase: ManagedAgentPhase, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { pub previous_agent_label: Option, @@ -89,6 +103,7 @@ pub struct TerminalState { pub terminal_title: Option, pub manual_label: Option, pub agent_name: Option, + managed_agent: Option, hook_report_sequences: HashMap, suppressed_full_lifecycle_hook_reports: HashMap, stale_full_lifecycle_hook_sessions: HashMap>, @@ -119,6 +134,7 @@ impl TerminalState { terminal_title: None, manual_label: None, agent_name: None, + managed_agent: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), stale_full_lifecycle_hook_sessions: HashMap::new(), @@ -1307,8 +1323,125 @@ impl TerminalState { self.agent_name = (!name.is_empty()).then_some(name); } + pub fn begin_managed_agent( + &mut self, + name: String, + kind: Agent, + now: Instant, + settle_delay: Duration, + timeout: Duration, + ) { + self.set_agent_name(name); + 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), + }, + }); + } + + pub fn managed_agent_launch_pending(&self) -> bool { + self.managed_agent + .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Pending { .. })) + } + + 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) + }) + } + + pub fn managed_agent_kind(&self) -> Option { + self.managed_agent.map(|managed| managed.kind) + } + + pub fn next_managed_agent_deadline(&self) -> Option { + let ManagedAgentPhase::Pending { + ready_after, + deadline, + } = self.managed_agent?.phase + else { + return None; + }; + Some(ready_after.unwrap_or(deadline).min(deadline)) + } + + pub fn reconcile_managed_agent_at(&mut self, now: Instant, process_exited: bool) -> bool { + let Some(managed) = self.managed_agent else { + return false; + }; + let known_agent = self.effective_known_agent(); + let observed_expected = managed.observed_expected || known_agent == Some(managed.kind); + 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; + return true; + } + if let ManagedAgentPhase::Pending { + ready_after, + deadline, + } = managed.phase + { + if now >= deadline { + self.managed_agent = None; + self.agent_name = None; + return true; + } + if ready_after.is_none_or(|ready_after| now >= ready_after) { + if known_agent == Some(managed.kind) + && matches!(self.state, AgentState::Idle | AgentState::Blocked) + { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + observed_expected: true, + phase: ManagedAgentPhase::Active, + }); + return true; + } + if ready_after.is_some() { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + observed_expected, + phase: ManagedAgentPhase::Pending { + ready_after: None, + deadline, + }, + }); + return true; + } + } + if observed_expected != managed.observed_expected { + self.managed_agent = Some(ManagedAgent { + observed_expected, + ..managed + }); + return true; + } + } + false + } + + pub fn restore_managed_agent(&mut self, name: String, kind: Agent) { + self.set_agent_name(name); + self.managed_agent = Some(ManagedAgent { + kind, + observed_expected: true, + phase: ManagedAgentPhase::Active, + }); + } + pub fn clear_agent_name(&mut self) { self.agent_name = None; + self.managed_agent = None; } pub fn clear_agent_runtime_identity_after_respawn(&mut self) { @@ -1413,6 +1546,62 @@ mod tests { .to_string() } + #[test] + fn managed_agent_activates_only_after_matching_settled_detection() { + let mut terminal = test_terminal(); + let now = Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::from_millis(100), + Duration::from_secs(1), + ); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); + assert!(!terminal.managed_agent_launch_pending()); + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal.set_detected_state(None, AgentState::Unknown); + 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)); + assert_eq!(terminal.agent_name, None); + } + + #[test] + fn managed_agent_mismatch_and_timeout_release_name() { + let now = Instant::now(); + let mut mismatch = test_terminal(); + mismatch.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::ZERO, + Duration::from_secs(1), + ); + mismatch.set_detected_state(Some(Agent::Codex), AgentState::Idle); + assert!(mismatch.reconcile_managed_agent_at(now, false)); + assert_eq!(mismatch.agent_name, None); + assert_eq!(mismatch.managed_agent_kind(), None); + + let mut timed_out = test_terminal(); + timed_out.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::from_millis(10), + Duration::from_millis(20), + ); + assert!(timed_out.reconcile_managed_agent_at(now + Duration::from_millis(20), false)); + assert_eq!(timed_out.agent_name, None); + assert_eq!(timed_out.managed_agent_kind(), None); + } + #[test] fn stabilization_uses_raw_policy_state() { let detection = AgentDetection { diff --git a/tests/api_ping.rs b/tests/api_ping.rs index a52e71c3..0e0b711e 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 16); + assert_eq!(value["result"]["protocol"], 17); cleanup_spawned_herdr(child, base); } @@ -1035,50 +1035,76 @@ fn new_terminal_cwd_follow_ignores_nonleader_group_member_cwd() { #[cfg(not(target_os = "macos"))] #[test] -fn agent_start_creates_named_terminal_over_socket() { +fn agent_start_targets_existing_pane_over_socket() { + use std::os::unix::fs::PermissionsExt; + let _lock = test_lock(); 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 20\n").unwrap(); + fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); - let child = spawn_herdr(&config_home, &runtime_dir, &socket_path); + let child = spawn_herdr_with_path(&config_home, &runtime_dir, &socket_path, Some(&bin)); wait_for_socket(&socket_path, Duration::from_secs(5)); - - let started = send_request( + let workspace = send_request( &socket_path, - &format!( - r#"{{"id":"agent_start","method":"agent.start","params":{{"name":"main","cwd":"{}","argv":["/bin/sh","-c","printf agent-start-ok; sleep 2"]}}}}"#, - base.display() - ), + &serde_json::json!({ + "id": "agent_workspace", + "method": "workspace.create", + "params": { "cwd": base.display().to_string(), "focus": false } + }) + .to_string(), ); - assert_eq!(started["result"]["type"], "agent_started"); - assert_eq!(started["result"]["agent"]["name"], "main"); - assert_eq!( - started["result"]["agent"]["cwd"], - base.display().to_string() - ); - assert_eq!(started["result"]["argv"][0], "/bin/sh"); - let terminal_id = started["result"]["agent"]["terminal_id"] + let pane_id = workspace["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let terminal_id = workspace["result"]["root_pane"]["terminal_id"] .as_str() .unwrap() .to_string(); - let listed = send_request( + let started = send_request( &socket_path, - r#"{"id":"agent_start_list","method":"agent.list","params":{}}"#, + &serde_json::json!({ + "id": "agent_start", + "method": "agent.start", + "params": { + "name": "main", + "kind": "pi", + "pane_id": pane_id, + "args": ["--no-session"], + "timeout_ms": 8_000 + } + }) + .to_string(), + ); + assert_eq!(started["result"]["type"], "agent_started"); + assert_eq!(started["result"]["agent"]["name"], "main"); + assert_eq!(started["result"]["agent"]["pane_id"], pane_id); + assert_eq!(started["result"]["agent"]["terminal_id"], terminal_id); + assert_eq!( + started["result"]["argv"], + serde_json::json!(["pi", "--no-session"]) ); - let agents = listed["result"]["agents"].as_array().unwrap(); - assert_eq!(agents.len(), 1); - assert_eq!(agents[0]["terminal_id"], terminal_id); - assert_eq!(agents[0]["name"], "main"); let duplicate = send_request( &socket_path, - &format!( - r#"{{"id":"agent_start_duplicate","method":"agent.start","params":{{"name":"main","cwd":"{}","argv":["/bin/sh","-c","true"]}}}}"#, - base.display() - ), + &serde_json::json!({ + "id": "agent_start_duplicate", + "method": "agent.start", + "params": { + "name": "main", + "kind": "pi", + "pane_id": pane_id + } + }) + .to_string(), ); assert_eq!(duplicate["error"]["code"], "agent_name_taken"); assert!(duplicate["error"]["message"] diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 8412e2d4..1b2c6407 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -325,6 +325,23 @@ fn run_cli_in_dir(socket_path: &Path, args: &[&str], current_dir: &Path) -> std: command.output().unwrap() } +fn pane_topology_snapshot(list_response: &serde_json::Value) -> Vec { + 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) @@ -1702,7 +1719,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 16"), + full_stdout.contains(" protocol: 17"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -1735,7 +1752,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 16"), + server_stdout.contains("protocol: 17"), "stdout: {server_stdout}" ); @@ -1747,7 +1764,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 16"), + client_stdout.contains("protocol: 17"), "stdout: {client_stdout}" ); assert!( @@ -1757,7 +1774,7 @@ fn status_commands_report_client_and_server_versions() { 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"], 16); + 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); @@ -1771,12 +1788,12 @@ fn status_commands_report_client_and_server_versions() { 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"], 16); + 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"], 16); + assert_eq!(client_json["protocol"], 17); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); @@ -2656,13 +2673,95 @@ fn tab_management_commands_work() { #[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(&config_home, &runtime_dir, &socket_path); + 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, @@ -2670,48 +2769,254 @@ fn agent_start_command_works() { "agent", "start", "main", - "--cwd", - base.to_str().unwrap(), + "--kind", + "pi", + "--pane", + &pane_id, + "--timeout", + "8000", "--", - "/bin/sh", - "-c", - "printf cli-agent-start-ok; sleep 2", - "--session", - "child-session", + "--name", + "scratch", + "--no-session", ], ); assert_eq!(started["result"]["type"], "agent_started"); assert_eq!(started["result"]["agent"]["name"], "main"); - assert_eq!(started["result"]["argv"][0], "/bin/sh"); - assert_eq!(started["result"]["argv"][3], "--session"); - assert_eq!(started["result"]["argv"][4], "child-session"); - let terminal_id = started["result"]["agent"]["terminal_id"] - .as_str() - .unwrap() - .to_string(); + 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 listed = run_cli_json(&socket_path, &["agent", "list"]); - assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id); - assert_eq!(listed["result"]["agents"][0]["name"], "main"); + 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 duplicate = run_cli( + 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", - "start", + "prompt", "main", - "--cwd", - base.to_str().unwrap(), - "--", - "/bin/sh", - "-c", - "true", + "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); } @@ -2751,20 +3056,179 @@ fn agent_commands_work() { 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( + let reported = run_cli( &socket_path, &[ - "agent", - "wait", - "worker", - "--status", - "unknown", - "--timeout", - "100", + "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"], @@ -2786,6 +3250,261 @@ fn agent_commands_work() { 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(); @@ -4502,6 +5221,65 @@ fn cli_allows_same_protocol_different_version_and_preserves_server_error() { 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(); @@ -4533,7 +5311,7 @@ fn agent_wait_rechecks_protocol_before_subscription() { assert_eq!(get_request["method"], "agent.get"); get_stream .write_all( - br#"{"id":"cli:agent:wait:resolve","result":{"type":"agent_info","agent":{"pane_id":"w1:p1","agent_status":"working"}}}"#, + 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(); @@ -4552,7 +5330,7 @@ fn agent_wait_rechecks_protocol_before_subscription() { let deadline = Instant::now() + Duration::from_millis(250); while Instant::now() < deadline { match listener.accept() { - Ok(_) => panic!("agent wait dispatched events.subscribe after mismatch"), + 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)); } @@ -4563,15 +5341,7 @@ fn agent_wait_rechecks_protocol_before_subscription() { let waited = run_cli( &socket_path, - &[ - "agent", - "wait", - "worker", - "--status", - "blocked", - "--timeout", - "5000", - ], + &["agent", "wait", "worker", "--timeout", "5000"], ); assert_eq!(waited.status.code(), Some(1)); diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index b2fb9ef6..9f607195 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1213,6 +1213,8 @@ fn live_handoff_accepts_canonical_pane_id_from_child_env() { #[test] fn live_handoff_keeps_agent_started_pane_after_agent_exits() { + use std::os::unix::fs::PermissionsExt; + let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1221,16 +1223,43 @@ fn live_handoff_keeps_agent_started_pane_after_agent_exits() { let started_marker = base.join("agent-started"); let exited_marker = base.join("agent-exited"); let shell_marker = base.join("shell-after-agent"); + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let fake_pi = bin.join("pi"); + fs::write( + &fake_pi, + format!( + "#!/bin/sh\nexport HERDR_AGENT=pi\necho started > {}\n/bin/sleep 1\necho exited > {}\n", + started_marker.display(), + exited_marker.display() + ), + ) + .unwrap(); + fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!("{}:/bin:/usr/bin", bin.display()); - let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + let spawned = spawn_server_with_env( + &config_home, + &runtime_dir, + &api_socket, + &[("PATH", path.as_str())], + ); wait_for_socket(&api_socket, Duration::from_secs(10)); register_runtime_dir(&runtime_dir); - - let command = format!( - "echo started > {}; sleep 1; echo exited > {}", - started_marker.display(), - exited_marker.display() + let workspace = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace-create", + "method": "workspace.create", + "params": { "cwd": "/tmp", "focus": false } + }), ); + assert_ok(workspace.clone()); + let pane_id = workspace["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let started = request( &api_socket, serde_json::json!({ @@ -1238,17 +1267,13 @@ fn live_handoff_keeps_agent_started_pane_after_agent_exits() { "method": "agent.start", "params": { "name": "handoff-agent", - "cwd": "/tmp", - "focus": true, - "argv": ["/bin/sh", "-c", command] + "kind": "pi", + "pane_id": pane_id, + "timeout_ms": 5000 } }), ); - assert_ok(started.clone()); - let pane_id = started["result"]["agent"]["pane_id"] - .as_str() - .unwrap() - .to_string(); + assert_ok(started); support::wait_for_file(&started_marker, Duration::from_secs(5)); assert_ok(request( diff --git a/tests/support/mod.rs b/tests/support/mod.rs index e4a9139a..ce723e97 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -15,7 +15,7 @@ static INIT: Once = Once::new(); static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; -pub const CURRENT_PROTOCOL: u32 = 16; +pub const CURRENT_PROTOCOL: u32 = 17; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else {