From 34b42e518e85d0ad64fee656ae145a902269fe38 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 16 Mar 2026 13:58:50 -0400 Subject: [PATCH 1/3] feat: add resize_pty protocol message for remote PTY resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When PTY agents are spawned by SDK clients (e.g. Electron apps), the worker process has no terminal attached, so SIGWINCH-based resize doesn't work. This adds a resize_pty message that flows from SDK → broker → worker, allowing clients to resize the PTY when their terminal emulator dimensions change. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/client.ts | 13 ++++++++++ packages/sdk/src/protocol.ts | 9 +++++++ src/main.rs | 48 ++++++++++++++++++++++++++++++++++++ src/protocol.rs | 4 +++ src/pty_worker.rs | 11 +++++++++ 5 files changed, 85 insertions(+) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 786202b93..eab6f7a04 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -367,6 +367,19 @@ export class AgentRelayClient { return this.requestOk<{ name: string; bytes_written: number }>('send_input', { name, data }); } + async resizePty( + name: string, + rows: number, + cols: number + ): Promise<{ name: string; rows: number; cols: number }> { + await this.start(); + return this.requestOk<{ name: string; rows: number; cols: number }>('resize_pty', { + name, + rows, + cols, + }); + } + async setModel( name: string, model: string, diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 04e6f3c0b..e3da8d15f 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -104,6 +104,11 @@ export type SdkToBroker = * the cache instead of waiting on individual HTTP registrations. */ type: 'preflight_agents'; payload: { agents: Array<{ name: string; cli: string }> }; + } + | { + /** Resize a PTY agent's terminal dimensions. */ + type: 'resize_pty'; + payload: { name: string; rows: number; cols: number }; }; export interface PendingDeliveryInfo { @@ -365,6 +370,10 @@ export type BrokerToWorker = | { type: 'ping'; payload: { ts_ms: number }; + } + | { + type: 'resize_pty'; + payload: { rows: number; cols: number }; }; export type WorkerToBroker = diff --git a/src/main.rs b/src/main.rs index 6a0f233ab..a7b9ea3b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -557,6 +557,13 @@ struct SendInputPayload { data: String, } +#[derive(Debug, Deserialize)] +struct ResizePtyPayload { + name: String, + rows: u16, + cols: u16, +} + #[derive(Debug, Deserialize)] struct SetModelPayload { name: String, @@ -4436,6 +4443,47 @@ async fn handle_sdk_frame( .await?; Ok(false) } + "resize_pty" => { + let payload: ResizePtyPayload = serde_json::from_value(frame.payload) + .context("resize_pty payload must contain `name`, `rows`, and `cols`")?; + + if !workers.has_worker(&payload.name) { + send_error( + out_tx, + frame.request_id, + "agent_not_found", + format!("unknown worker '{}'", payload.name), + false, + None, + ) + .await?; + return Ok(false); + } + + workers + .send_to_worker( + &payload.name, + "resize_pty", + None, + json!({ + "rows": payload.rows, + "cols": payload.cols, + }), + ) + .await?; + + send_ok( + out_tx, + frame.request_id, + json!({ + "name": payload.name, + "rows": payload.rows, + "cols": payload.cols, + }), + ) + .await?; + Ok(false) + } "set_model" => { let payload: SetModelPayload = serde_json::from_value(frame.payload) .context("set_model payload must contain `name` and `model`")?; diff --git a/src/protocol.rs b/src/protocol.rs index 682dc28ff..781158161 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -254,6 +254,10 @@ pub enum BrokerToWorker { Ping { ts_ms: u64, }, + ResizePty { + rows: u16, + cols: u16, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/pty_worker.rs b/src/pty_worker.rs index 8f2365456..f07da9a17 100644 --- a/src/pty_worker.rs +++ b/src/pty_worker.rs @@ -338,6 +338,17 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> { "shutdown_worker" => { running = false; } + "resize_pty" => { + let rows = frame.payload.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16; + let cols = frame.payload.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16; + if let Err(e) = pty.resize(rows, cols) { + tracing::warn!( + target = "agent_relay::worker::pty", + rows, cols, error = %e, + "failed to resize pty" + ); + } + } "ping" => { let ts = frame.payload.get("ts_ms").and_then(Value::as_u64).unwrap_or_default(); let _ = send_frame(&out_tx, "pong", frame.request_id, json!({"ts_ms": ts})).await; From 511cff4732aa446b74445238fd0d330676ca213c Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 16 Mar 2026 14:22:36 -0400 Subject: [PATCH 2/3] fix: address PR review feedback for resize_pty - Reject headless agents with unsupported_operation error - Validate rows/cols >= 1, return invalid_dimensions error for zeros - Use typed struct deserialization in pty_worker instead of manual JSON field extraction with silent defaults - Add BrokerToWorker::ResizePty round-trip test with wire format checks Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 28 +++++++++++++++++++++++++++- src/protocol.rs | 17 +++++++++++++++++ src/pty_worker.rs | 34 ++++++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index a7b9ea3b2..51c2172db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4447,7 +4447,20 @@ async fn handle_sdk_frame( let payload: ResizePtyPayload = serde_json::from_value(frame.payload) .context("resize_pty payload must contain `name`, `rows`, and `cols`")?; - if !workers.has_worker(&payload.name) { + if payload.rows == 0 || payload.cols == 0 { + send_error( + out_tx, + frame.request_id, + "invalid_dimensions", + "rows and cols must be >= 1".to_string(), + false, + None, + ) + .await?; + return Ok(false); + } + + let Some(handle) = workers.workers.get(&payload.name) else { send_error( out_tx, frame.request_id, @@ -4458,6 +4471,19 @@ async fn handle_sdk_frame( ) .await?; return Ok(false); + }; + + if handle.spec.runtime != AgentRuntime::Pty { + send_error( + out_tx, + frame.request_id, + "unsupported_operation", + format!("resize_pty is only supported for PTY agents, '{}' is {:?}", payload.name, handle.spec.runtime), + false, + None, + ) + .await?; + return Ok(false); } workers diff --git a/src/protocol.rs b/src/protocol.rs index 781158161..5b8427b09 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -450,4 +450,21 @@ mod tests { let decoded: AgentSpec = serde_json::from_str(&encoded).unwrap(); assert_eq!(decoded.provider, Some(HeadlessProvider::Opencode)); } + + #[test] + fn broker_to_worker_resize_pty_round_trip() { + let msg = BrokerToWorker::ResizePty { + rows: 40, + cols: 120, + }; + let encoded = serde_json::to_string(&msg).unwrap(); + let decoded: BrokerToWorker = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, msg); + + // Verify wire format uses snake_case tag + let raw: Value = serde_json::from_str(&encoded).unwrap(); + assert_eq!(raw["type"], "resize_pty"); + assert_eq!(raw["payload"]["rows"], 40); + assert_eq!(raw["payload"]["cols"], 120); + } } diff --git a/src/pty_worker.rs b/src/pty_worker.rs index f07da9a17..6217b4613 100644 --- a/src/pty_worker.rs +++ b/src/pty_worker.rs @@ -339,14 +339,32 @@ pub(crate) async fn run_pty_worker(cmd: PtyCommand) -> Result<()> { running = false; } "resize_pty" => { - let rows = frame.payload.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16; - let cols = frame.payload.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16; - if let Err(e) = pty.resize(rows, cols) { - tracing::warn!( - target = "agent_relay::worker::pty", - rows, cols, error = %e, - "failed to resize pty" - ); + #[derive(serde::Deserialize)] + struct ResizePtyPayload { rows: u16, cols: u16 } + match serde_json::from_value::(frame.payload) { + Ok(p) if p.rows > 0 && p.cols > 0 => { + if let Err(e) = pty.resize(p.rows, p.cols) { + tracing::warn!( + target = "agent_relay::worker::pty", + rows = p.rows, cols = p.cols, error = %e, + "failed to resize pty" + ); + } + } + Ok(_) => { + let _ = send_frame(&out_tx, "worker_error", frame.request_id, json!({ + "code": "invalid_dimensions", + "message": "rows and cols must be >= 1", + "retryable": false + })).await; + } + Err(e) => { + let _ = send_frame(&out_tx, "worker_error", frame.request_id, json!({ + "code": "invalid_payload", + "message": e.to_string(), + "retryable": false + })).await; + } } } "ping" => { From 35bf87dfcb2918e1fd1dc8744f01fdf1c1eaf58e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 18:23:33 +0000 Subject: [PATCH 3/3] style: auto-format Rust code with cargo fmt --- src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 51c2172db..5044bb425 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4478,7 +4478,10 @@ async fn handle_sdk_frame( out_tx, frame.request_id, "unsupported_operation", - format!("resize_pty is only supported for PTY agents, '{}' is {:?}", payload.name, handle.spec.runtime), + format!( + "resize_pty is only supported for PTY agents, '{}' is {:?}", + payload.name, handle.spec.runtime + ), false, None, )