feat(tui): /rc remote-control host for a running session (CWC #119) (#4844)

Makes a live Codewhale CLI/TUI session enrollable as a remote-controlled
host, so an authenticated CWC browser session can drive the terminal that is
already running rather than starting a second runtime.

The design constraint that shapes everything here: attaching must not create
a second execution history. The in-process session stays the sole owner of
model and tool state; the web receives a bounded presentation snapshot
followed by live envelopes on a strictly increasing sequence.

Ownership is one durable input owner, not a merge. While a lease is active
the web owns new prompts and approval decisions and the terminal stays a
readable view that still accepts /rc status, /rc stop, and interrupt. Local
submission is refused with a visible notice rather than silently queued, so
there is no last-writer-wins path.

Failure modes fail closed rather than guessing. Cancelling a connect cannot
prove no server-side lease exists, so ownership stays locked through a
conservative expiry instead of returning local input immediately. Commands
are fingerprinted by (run, seq) so an in-process duplicate is suppressed
without a second effect while a sequence collision with different content is
rejected. Enrollment binds the exact semantic version and full build commit,
and connect must repeat them, so CWC never has to loosen a version check to
accept this build.

/rc stop returns prompt and approval ownership to the terminal without ending
the session, and pending approvals are handed back for local decision rather
than being dropped.

Rebased from the pre-#4835 base onto current main; the lane's test fixture
was written against an older PendingRemoteApproval shape and is updated to
the real one.

Refs Hmbown/cwc#119, Hmbown/cwc#120.
This commit is contained in:
Hunter Bown
2026-07-25 19:31:33 -07:00
committed by GitHub
parent f215d3ff32
commit ff04c43368
20 changed files with 2314 additions and 15 deletions
+42 -11
View File
@@ -24,11 +24,17 @@ pub fn declare_rerun_conditions(manifest_dir: &Path) {
/// `manifest_dir` and `package_version` are the calling build script's
/// `CARGO_MANIFEST_DIR` and `CARGO_PKG_VERSION`.
pub fn emit_build_version(manifest_dir: &Path, package_version: &str) {
let build_version = build_sha(manifest_dir)
let commit = build_commit(manifest_dir);
let build_version = commit
.as_ref()
.and_then(|sha| short_sha(sha.clone()))
.map(|sha| format!("{package_version} ({sha})"))
.unwrap_or_else(|| package_version.to_string());
println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
if let Some(commit) = commit {
println!("cargo:rustc-env=CODEWHALE_BUILD_COMMIT={commit}");
}
}
/// Tell Cargo to invalidate the cached build script output when `HEAD`
@@ -117,17 +123,17 @@ fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
.filter(|s| !s.is_empty())
}
fn build_sha(manifest_dir: &Path) -> Option<String> {
env_sha("DEEPSEEK_BUILD_SHA")
.or_else(|| env_sha("GITHUB_SHA"))
.or_else(|| git_sha(manifest_dir))
fn build_commit(manifest_dir: &Path) -> Option<String> {
env_commit("DEEPSEEK_BUILD_SHA")
.or_else(|| env_commit("GITHUB_SHA"))
.or_else(|| git_commit(manifest_dir))
}
fn env_sha(name: &str) -> Option<String> {
std::env::var(name).ok().and_then(short_sha)
fn env_commit(name: &str) -> Option<String> {
std::env::var(name).ok().and_then(full_sha)
}
fn git_sha(manifest_dir: &Path) -> Option<String> {
fn git_commit(manifest_dir: &Path) -> Option<String> {
let top_level_output = Command::new("git")
.args(["-C"])
.arg(manifest_dir)
@@ -145,14 +151,22 @@ fn git_sha(manifest_dir: &Path) -> Option<String> {
let output = Command::new("git")
.args(["-C"])
.arg(top_level)
.args(["rev-parse", "--short=12", "HEAD"])
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
short_sha(String::from_utf8_lossy(&output.stdout).to_string())
full_sha(String::from_utf8_lossy(&output.stdout).to_string())
}
fn full_sha(value: String) -> Option<String> {
let trimmed = value.trim().to_ascii_lowercase();
if trimmed.len() != 40 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
Some(trimmed)
}
fn short_sha(value: String) -> Option<String> {
@@ -165,7 +179,7 @@ fn short_sha(value: String) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{git_common_dir, parse_symbolic_ref};
use super::{full_sha, git_common_dir, parse_symbolic_ref, short_sha};
use std::{
fs,
time::{SystemTime, UNIX_EPOCH},
@@ -187,6 +201,23 @@ mod tests {
);
}
#[test]
fn full_commit_requires_exact_forty_hex_characters() {
assert_eq!(
full_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
Some("abcdef0123456789abcdef0123456789abcdef01".to_string())
);
assert_eq!(full_sha("abc123".to_string()), None);
assert_eq!(
full_sha("gggggggggggggggggggggggggggggggggggggggg".to_string()),
None
);
assert_eq!(
short_sha("abcdef0123456789abcdef0123456789abcdef01".to_string()),
Some("abcdef012345".to_string())
);
}
#[test]
fn detached_head_is_not_a_symbolic_ref() {
assert_eq!(
+18
View File
@@ -233,6 +233,8 @@ enum Commands {
Sessions(TuiPassthroughArgs),
/// Resume a saved TUI session.
Resume(TuiPassthroughArgs),
/// Launch an interactive session and hand it to the Codewhale web app.
Rc(TuiPassthroughArgs),
/// Fork a saved TUI session.
Fork(TuiPassthroughArgs),
/// Create a default AGENTS.md in the current directory.
@@ -1661,6 +1663,12 @@ fn run() -> Result<()> {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
run_resume_command(&cli, &resolved_runtime, args)
}
Some(Commands::Rc(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
let mut passthrough = vec!["--remote-control".to_string()];
passthrough.extend(args.args);
delegate_to_tui(&cli, &resolved_runtime, passthrough)
}
Some(Commands::Fork(args)) => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
@@ -8269,6 +8277,16 @@ model = "qwen-2.5-7b"
assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
}
#[test]
fn parses_rc_as_the_account_owned_interactive_handoff() {
let cli = parse_ok(&["codewhale", "rc"]);
let Some(Commands::Rc(args)) = cli.command else {
panic!("rc should parse as the remote-control TUI handoff");
};
assert!(args.args.is_empty());
}
#[test]
fn top_level_continue_rejects_startup_prompt() {
let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
+1
View File
@@ -252,6 +252,7 @@
"CmdQueueIndexPositive": "Index must be a positive number",
"CmdQueueIndexMin": "Index must be >= 1",
"CmdRelayDescription": "Create a session relay (接力) for a fresh thread",
"CmdRemoteControlDescription": "Resume this exact session from your Codewhale web account",
"CmdRenameDescription": "Rename the current session",
"CmdRestoreDescription": "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.",
"CmdRetryDescription": "Retry the last request",
+1
View File
@@ -252,6 +252,7 @@
"CmdQueueIndexPositive": "El índice debe ser un número positivo",
"CmdQueueIndexMin": "El índice debe ser >= 1",
"CmdRelayDescription": "Crear un relay de sesión (接力) para un hilo nuevo",
"CmdRemoteControlDescription": "Reanudar esta sesión exacta desde tu cuenta web de Codewhale",
"CmdRenameDescription": "Renombrar la sesión actual",
"CmdRestoreDescription": "Revertir el workspace a un snapshot pre/post-turno anterior. Sin argumento, lista los snapshots recientes.",
"CmdRetryDescription": "Repetir la última solicitud",
+1
View File
@@ -252,6 +252,7 @@
"CmdQueueIndexPositive": "インデックスは正の数値である必要があります",
"CmdQueueIndexMin": "インデックスは 1 以上である必要があります",
"CmdRelayDescription": "新しいスレッド用のセッションリレー(接力)を作成",
"CmdRemoteControlDescription": "Codewhaleウェブアカウントからこのセッションを再開",
"CmdRenameDescription": "現在のセッションの名前を変更",
"CmdRestoreDescription": "ワークスペースを以前のターン前/後スナップショットへロールバック。引数なしで最近のスナップショットを一覧表示。",
"CmdRetryDescription": "直前のリクエストを再試行",
+1
View File
@@ -252,6 +252,7 @@
"CmdQueueIndexPositive": "인덱스는 양수여야 합니다",
"CmdQueueIndexMin": "인덱스는 1 이상이어야 합니다",
"CmdRelayDescription": "새 스레드를 위한 세션 릴레이(接力)를 생성합니다",
"CmdRemoteControlDescription": "Codewhale 웹 계정에서 이 세션을 그대로 재개합니다",
"CmdRenameDescription": "현재 세션 이름을 바꿉니다",
"CmdRestoreDescription": "작업 공간을 이전 턴 전/후 스냅샷으로 되돌립니다. 인자가 없으면 최근 스냅샷 목록을 표시합니다.",
"CmdRetryDescription": "마지막 요청을 재시도합니다",
+1
View File
@@ -252,6 +252,7 @@
"CmdQueueIndexPositive": "O índice deve ser um número positivo",
"CmdQueueIndexMin": "O índice deve ser >= 1",
"CmdRelayDescription": "Criar um relay da sessão para um novo thread",
"CmdRemoteControlDescription": "Retomar esta sessão exata pela sua conta web do Codewhale",
"CmdRenameDescription": "Renomear a sessão atual",
"CmdRestoreDescription": "Reverter o workspace a um snapshot pré/pós-turno anterior. Sem argumento, lista os snapshots recentes.",
"CmdRetryDescription": "Repetir a última requisição",
+1
View File
@@ -252,6 +252,7 @@
"CmdQueueIndexPositive": "Chỉ mục phải là số dương",
"CmdQueueIndexMin": "Chỉ mục phải >= 1",
"CmdRelayDescription": "Tạo một phiên tiếp sức cho một luồng mới",
"CmdRemoteControlDescription": "Tiếp tục chính phiên này từ tài khoản web Codewhale",
"CmdRenameDescription": "Đổi tên phiên làm việc hiện tại",
"CmdRestoreDescription": "Khôi phục không gian làm việc về bản chụp trước/sau lượt. Nếu không có đối số, hiển thị các bản chụp gần đây.",
"CmdRetryDescription": "Thử lại yêu cầu gần nhất",
+1
View File
@@ -252,6 +252,7 @@
"CmdQueueIndexPositive": "索引必须为正数",
"CmdQueueIndexMin": "索引必须 >= 1",
"CmdRelayDescription": "为新线程创建会话接力摘要",
"CmdRemoteControlDescription": "从 Codewhale 网页账户继续这个会话",
"CmdRenameDescription": "重命名当前会话",
"CmdRestoreDescription": "将工作区回滚到此前的轮次前/后快照。不带参数时列出最近的快照。",
"CmdRetryDescription": "重试上一次请求",
+1
View File
@@ -5,6 +5,7 @@
"ComposerDispatchFailedRestored": "訊息未傳送({error});已還原到輸入框。",
"CmdAuthDescription": "管理提供者驗證流程",
"CmdRelayDescription": "為新執行緒建立會話接力摘要",
"CmdRemoteControlDescription": "從 Codewhale 網頁帳戶繼續這個會話",
"CmdHotbarDescription": "開啟 Hotbar 設定",
"KbJumpPlanAgentYolo": "觸發 Hotbar 槽位",
"KbAltJumpPlanAgentYolo": "替代快捷鍵跳到 Plan / Act / Operate 模式",
@@ -10,6 +10,7 @@ mod load;
mod new;
mod purge;
mod relay;
mod remote_control;
mod rename;
#[cfg(test)]
pub(crate) use rename::rename_with_manager;
@@ -65,6 +66,10 @@ impl CommandGroup for SessionCommands {
relay::RelayCmd::info(),
relay::RelayCmd::execute,
)),
Box::new(FunctionCommand::new(
remote_control::RemoteControlCmd::info(),
remote_control::RemoteControlCmd::execute,
)),
Box::new(FunctionCommand::new(
export::ExportCmd::info(),
export::ExportCmd::execute,
@@ -0,0 +1,64 @@
//! `/rc` account-owned web remote control.
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::localization::MessageId;
use crate::remote_control::RemoteControlAction;
use crate::tui::app::{App, AppAction};
use super::CommandResult;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "rc",
aliases: &["remote-control"],
usage: "/rc [status|stop]",
description_id: MessageId::CmdRemoteControlDescription,
};
pub(in crate::commands) struct RemoteControlCmd;
impl RegisterCommand for RemoteControlCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
match arg.map(str::trim).filter(|value| !value.is_empty()) {
None | Some("start") => {
if app.is_loading {
return CommandResult::error(
"Finish or interrupt the current turn before handing this session to the web.",
);
}
CommandResult::with_message_and_action(
"Starting account-owned web remote control…",
AppAction::RemoteControl(RemoteControlAction::Start),
)
}
Some("status") => CommandResult::message(app.remote_control.status_line()),
Some("stop") => CommandResult::with_message_and_action(
"Stopping web remote control…",
AppAction::RemoteControl(RemoteControlAction::Stop),
),
Some(_) => CommandResult::error("Usage: /rc [status|stop]"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
#[test]
fn start_is_blocked_during_an_active_turn() {
let options = TuiOptions {
..crate::test_support::test_tui_options(PathBuf::from("."))
};
let mut app = crate::test_support::test_app_with_options(options);
app.is_loading = true;
let result = RemoteControlCmd::execute(&mut app, None);
assert!(result.is_error);
assert!(result.action.is_none());
}
}
+2
View File
@@ -343,6 +343,7 @@ pub enum MessageId {
CmdQueueIndexPositive,
CmdQueueIndexMin,
CmdRelayDescription,
CmdRemoteControlDescription,
CmdRenameDescription,
CmdRestoreDescription,
CmdRetryDescription,
@@ -1525,6 +1526,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdQueueIndexPositive,
MessageId::CmdQueueIndexMin,
MessageId::CmdRelayDescription,
MessageId::CmdRemoteControlDescription,
MessageId::CmdRenameDescription,
MessageId::CmdRestoreDescription,
MessageId::CmdRetryDescription,
+16
View File
@@ -86,6 +86,7 @@ mod provider_lake;
mod provider_readiness;
mod purge;
mod regex_cache;
mod remote_control;
mod remote_setup;
pub mod repl;
mod repo_law;
@@ -227,6 +228,10 @@ struct Cli {
#[arg(long)]
skip_onboarding: bool,
/// Start account-owned web remote control for this interactive session.
#[arg(long, hide = true)]
remote_control: bool,
/// Start a fresh session, ignoring any crash-recovery checkpoint
#[arg(long = "fresh")]
fresh: bool,
@@ -8911,6 +8916,11 @@ async fn run_interactive(
initial_input: Option<tui::InitialInput>,
plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
) -> Result<()> {
let initial_input = if cli.remote_control {
Some(tui::InitialInput::RemoteControl)
} else {
initial_input
};
let workspace = cli
.workspace
.clone()
@@ -12646,6 +12656,12 @@ mod terminal_mode_tests {
Cli::try_parse_from(args).expect("CLI args should parse")
}
#[test]
fn hidden_remote_control_flag_starts_the_interactive_handoff() {
let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
assert!(cli.remote_control);
}
#[test]
fn plugin_registry_discovery_is_route_independent_and_read_only() {
let _env_lock = crate::test_support::lock_test_env();
File diff suppressed because it is too large Load Diff
+3
View File
@@ -978,6 +978,9 @@ pub struct App {
/// Monotonic counter used to issue fresh per-cell revisions.
pub next_history_revision: u64,
pub api_messages: Vec<Message>,
/// Typed account-owned browser relay for this exact TUI session.
pub remote_control: crate::remote_control::RemoteControlController,
pub start_remote_control_on_launch: bool,
pub is_loading: bool,
/// Sender for spawned dispatch tasks to report completion back to the
/// event loop. The closure is called with `&mut App` so the async phase
+4
View File
@@ -473,6 +473,7 @@ impl App {
let input_history = crate::composer_history::load_history();
let mention_cwd = std::env::current_dir().ok();
let start_remote_control = matches!(initial_input, Some(InitialInput::RemoteControl));
let (initial_input_text, initial_input_cursor, auto_submit_initial_input) =
match initial_input {
// #451: pre-populate the composer when invoked via
@@ -487,6 +488,7 @@ impl App {
let cursor = text.chars().count();
(text, cursor, true)
}
Some(InitialInput::RemoteControl) => (String::new(), 0, false),
_ => (String::new(), 0, false),
};
let mcp_configured_count = crate::mcp::load_config_with_workspace_and_plugins(
@@ -551,6 +553,8 @@ impl App {
history_revisions: Vec::new(),
next_history_revision: 1,
api_messages: Vec::new(),
remote_control: crate::remote_control::RemoteControlController::default(),
start_remote_control_on_launch: start_remote_control,
is_loading: false,
dispatch_completion_tx: None,
dispatch_in_flight: false,
+3
View File
@@ -540,6 +540,8 @@ pub enum InitialInput {
/// Pre-populate the composer, submit it once startup is ready, then keep
/// the interactive session open for follow-up messages (#2370).
Submit(String),
/// Begin account-owned web remote control after the TUI is initialized.
RemoteControl,
}
// === Sub-state structs for App field organization (#377) ===
@@ -691,6 +693,7 @@ pub enum AppAction {
SaveSession(PathBuf),
#[allow(dead_code)] // For explicit /load command
LoadSession(PathBuf),
RemoteControl(crate::remote_control::RemoteControlAction),
SyncSession {
session_id: Option<String>,
messages: Vec<Message>,
+343 -4
View File
@@ -138,9 +138,9 @@ use super::key_actions;
use super::app::{
ActiveTurnMetadata, AgentCurrentActivity, AgentCurrentActivityStatus, App, AppAction, AppMode,
HuntVerdict, OnboardingState, PendingProviderSwitch, QueuedMessage, ReasoningEffort,
SidebarFocus, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
ToolEvidence, TuiOptions, bound_agent_activity_text, looks_like_slash_command_input,
shell_command_from_bang_input,
SidebarFocus, StatusToast, StatusToastLevel, SubmitDisposition, TaskPanelEntry,
TaskPanelEntryKind, ToolEvidence, TuiOptions, bound_agent_activity_text,
looks_like_slash_command_input, shell_command_from_bang_input,
};
use super::approval::{
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
@@ -870,6 +870,238 @@ fn surface_prompt_override_notices(app: &mut App) {
}
}
async fn drain_remote_control_events(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
) -> Result<bool> {
let mut changed = false;
while let Some(event) = app.remote_control.try_next_event() {
changed = true;
match event {
crate::remote_control::RemoteEvent::Notice(message) => {
app.add_message(HistoryCell::System {
content: message.clone(),
});
app.status_message = Some(message.clone());
app.sticky_status =
Some(StatusToast::new(message, StatusToastLevel::Warning, None));
}
crate::remote_control::RemoteEvent::Connected {
account_ref,
runner_id,
..
} => {
let status = format!(
"REMOTE CONTROL · account {account_ref} · runner {runner_id} · /rc stop returns input here"
);
app.add_message(HistoryCell::System {
content: format!(
"{status}\n\nThe web now owns new prompts and approvals. This terminal remains readable."
),
});
app.status_message = Some(status.clone());
app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
}
crate::remote_control::RemoteEvent::Failed(error) => {
let status = format!(
"REMOTE CONTROL LOST · {error} · input stays locked until the server lease expires"
);
app.status_message = Some(status.clone());
app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Error, None));
}
crate::remote_control::RemoteEvent::Stopped => {
app.sticky_status = None;
app.status_message =
Some("Remote control stopped; this terminal owns input again.".to_string());
}
crate::remote_control::RemoteEvent::OwnershipRestored { approvals } => {
app.sticky_status = None;
app.status_message = Some(
"The remote lease expired safely; this terminal owns input again.".to_string(),
);
for approval in approvals {
push_approval_request_view(
app,
&approval.tool_id,
&approval.tool_name,
&approval.description,
&approval.input,
&approval.approval_key,
approval.intent_summary.as_deref(),
);
}
}
crate::remote_control::RemoteEvent::Command {
run_id,
seq,
command,
} => {
match app.remote_control.claim_command(&run_id, seq, &command) {
Ok(true) => {}
Ok(false) => continue,
Err(error) => {
app.remote_control.acknowledge(
&run_id,
seq,
&command,
"failed",
Some(error.clone()),
);
app.remote_control.stop();
app.sticky_status = None;
app.status_message = Some(error);
continue;
}
}
match command.clone() {
crate::remote_control::RemoteCommand::Prompt { turn_id, prompt } => {
if app.is_loading || app.dispatch_in_flight {
app.remote_control.acknowledge(
&run_id,
seq,
&command,
"failed",
Some(
"The exact session is already running a turn; no second owner was started."
.to_string(),
),
);
continue;
}
app.remote_control
.upload_snapshot(&run_id, &app.api_messages);
app.remote_control.activate_prompt(&run_id, &turn_id);
let message = QueuedMessage::new(prompt, None);
app.remote_control.set_applying_remote_command(true);
let result =
dispatch_user_message(app, config, engine_handle, message).await;
app.remote_control.set_applying_remote_command(false);
match result {
Ok(()) if app.is_loading || app.dispatch_in_flight => {
app.remote_control
.acknowledge(&run_id, seq, &command, "applied", None);
}
Ok(()) => {
app.remote_control.acknowledge(
&run_id,
seq,
&command,
"failed",
Some(
"The remote prompt was blocked before dispatch."
.to_string(),
),
);
}
Err(error) => {
app.remote_control.acknowledge(
&run_id,
seq,
&command,
"failed",
Some(error.to_string()),
);
}
}
}
crate::remote_control::RemoteCommand::Approval { gate, approved } => {
let Some(tool_id) = app.remote_control.take_pending_approval(&gate) else {
app.remote_control.acknowledge(
&run_id,
seq,
&command,
"failed",
Some("This approval is no longer pending.".to_string()),
);
continue;
};
let result = if approved {
engine_handle.approve_tool_call(tool_id).await
} else {
engine_handle.deny_tool_call(tool_id).await
};
match result {
Ok(()) => app
.remote_control
.acknowledge(&run_id, seq, &command, "applied", None),
Err(error) => app.remote_control.acknowledge(
&run_id,
seq,
&command,
"failed",
Some(error.to_string()),
),
}
}
crate::remote_control::RemoteCommand::Control { .. } => {
if !app.remote_control.active_run_matches(&run_id) {
app.remote_control.acknowledge(
&run_id,
seq,
&command,
"failed",
Some("This run no longer owns an active turn.".to_string()),
);
continue;
}
engine_handle.cancel();
mark_active_turn_cancelled_locally(app);
app.remote_control
.acknowledge(&run_id, seq, &command, "applied", None);
}
}
}
}
}
Ok(changed)
}
fn start_remote_control_session(app: &mut App) {
if app.is_loading {
app.status_message = Some(
"Finish or interrupt the current turn before handing this session to the web."
.to_string(),
);
return;
}
let session_id = app
.current_session_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
app.current_session_id = Some(session_id.clone());
let target_ref = crate::remote_control::target_ref(&app.workspace, &session_id);
let workspace_label = app
.workspace
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.unwrap_or("Codewhale session")
.to_string();
let runtime_commit = option_env!("CODEWHALE_BUILD_COMMIT")
.unwrap_or("")
.to_string();
match app
.remote_control
.start(crate::remote_control::RemoteStart {
workspace_label,
target_ref,
session_id,
runtime_version: env!("CARGO_PKG_VERSION").to_string(),
runtime_commit,
}) {
Ok(()) => {
let status = app.remote_control.status_line();
app.status_message = Some(status.clone());
app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
}
Err(error) => {
app.status_message = Some(error.clone());
app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
}
}
}
/// Run the interactive TUI event loop.
///
/// # Examples
@@ -1267,6 +1499,9 @@ pub async fn run_tui(
tokio::sync::mpsc::unbounded_channel::<crate::tui::app::DispatchApplyFn>();
app.dispatch_completion_tx = Some(dispatch_completion_tx);
if std::mem::take(&mut app.start_remote_control_on_launch) {
start_remote_control_session(&mut app);
}
submit_initial_input_if_ready(&mut app, config, &engine_handle).await?;
crate::startup_trace::log_summary();
@@ -2555,6 +2790,10 @@ async fn run_event_loop(
// potentially long engine batch so composer/modal input stays live.
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
if drain_remote_control_events(app, config, &engine_handle).await? {
app.needs_redraw = true;
}
// First, poll for engine events (non-blocking)
let mut received_engine_event = false;
let mut transcript_batch_updated = false;
@@ -2611,6 +2850,9 @@ async fn run_event_loop(
} else if !app.is_loading && ignore_stale_stream_event_while_idle(&event) {
continue;
}
if !matches!(event, EngineEvent::ApprovalRequired { .. }) {
app.remote_control.observe_engine_event(&event);
}
record_turn_activity(app, &event, Instant::now());
match event {
EngineEvent::MessageStarted { .. } => {
@@ -3887,6 +4129,27 @@ async fn run_event_loop(
intent_summary,
approval_force_prompt,
} => {
if app.remote_control.blocks_local_input() {
let gate = app.remote_control.record_remote_approval(
&id,
&tool_name,
&description,
&input,
&approval_key,
intent_summary.as_deref(),
);
app.status_message = Some(format!(
"Remote approval required for '{tool_name}' ({gate}); decide in the web session."
));
app.sticky_status = Some(StatusToast::new(
format!(
"REMOTE CONTROL · approval waiting in web · {tool_name} · /rc stop"
),
StatusToastLevel::Warning,
None,
));
continue;
}
use crate::core::authority::ApprovalRequestDisposition;
// One disposition path for every ApprovalRequired (#4412):
// session denial, Full Access policy hold, session/FA
@@ -3998,7 +4261,27 @@ async fn run_event_loop(
}
}
EngineEvent::UserInputRequired { id, request } => {
if should_suppress_user_input_prompt(app) {
if app.remote_control.blocks_local_input() {
// Remote-control v1 deliberately admits only prompts, approval
// decisions, and run control. Do not leak a second controller
// through a local structured-question modal.
log_sensitive_event(
"tool.user_input.cancelled_remote_control",
serde_json::json!({
"tool_id": id.clone(),
"session_id": app.current_session_id,
}),
);
let _ = engine_handle.cancel_user_input(id).await;
app.pending_user_input_prompt = None;
let notice = "A structured question was cancelled because the web owns input; ask it as a normal web prompt instead.".to_string();
app.push_status_toast(
notice.clone(),
StatusToastLevel::Warning,
Some(8_000),
);
app.status_message = Some(notice);
} else if should_suppress_user_input_prompt(app) {
// A question may have been planned just before the
// user switched to Auto-Review. Cancel the stale
// request instead of opening a modal under an Auto
@@ -6137,6 +6420,9 @@ async fn run_event_loop(
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
if let Some(input) = app.submit_input() {
if reject_local_input_while_remote(app, &input) {
continue;
}
if handle_bang_shell_input(app, &engine_handle, &input).await? {
continue;
}
@@ -6188,6 +6474,9 @@ async fn run_event_loop(
&& (matches!(key.code, KeyCode::Enter) || app.is_loading) =>
{
if let Some(input) = app.submit_input() {
if reject_local_input_while_remote(app, &input) {
continue;
}
if handle_bang_shell_input(app, &engine_handle, &input).await? {
continue;
}
@@ -6250,6 +6539,9 @@ async fn run_event_loop(
}
}
if let Some(input) = app.handle_composer_enter() {
if reject_local_input_while_remote(app, &input) {
continue;
}
// `# foo` quick-add (#492) — when memory is enabled,
// a single line starting with `#` (but not `##` /
// `#!` shebangs / Markdown headings the user might
@@ -11014,6 +11306,25 @@ async fn apply_command_result(
content: format_task_list(&tasks),
});
}
AppAction::RemoteControl(action) => match action {
crate::remote_control::RemoteControlAction::Start => {
start_remote_control_session(app);
}
crate::remote_control::RemoteControlAction::Stop => {
app.remote_control.stop();
let status = app.remote_control.status_line();
if app.remote_control.blocks_local_input() {
app.sticky_status = Some(StatusToast::new(
status.clone(),
StatusToastLevel::Warning,
None,
));
} else {
app.sticky_status = None;
}
app.status_message = Some(status);
}
},
AppAction::TaskShow { id } => match task_manager.get_task(&id).await {
Ok(task) => open_task_pager(app, &task),
Err(err) => {
@@ -11901,6 +12212,16 @@ async fn submit_or_steer_message(
engine_handle: &EngineHandle,
message: QueuedMessage,
) -> Result<()> {
if app.remote_control.blocks_local_input() {
app.input = message.display;
app.cursor_position = app.input.chars().count();
let status =
"Web remote control owns prompts. Use /rc stop to return input to this terminal."
.to_string();
app.status_message = Some(status.clone());
app.push_status_toast(status, StatusToastLevel::Warning, Some(6_000));
return Ok(());
}
match app
.enter_with_double_tap()
.unwrap_or(SubmitDisposition::Immediate)
@@ -11946,6 +12267,24 @@ async fn submit_or_steer_message(
}
}
fn reject_local_input_while_remote(app: &mut App, input: &str) -> bool {
if !app.remote_control.blocks_local_input()
|| input
.split_whitespace()
.next()
.is_some_and(|value| matches!(value, "/rc" | "/remote-control"))
{
return false;
}
app.input = input.to_string();
app.cursor_position = app.input.chars().count();
let status = "Web remote control owns prompts. Use /rc stop to return input to this terminal."
.to_string();
app.status_message = Some(status.clone());
app.push_status_toast(status, StatusToastLevel::Warning, Some(6_000));
true
}
fn restore_failed_immediate_submit(app: &mut App, message: QueuedMessage, error: &anyhow::Error) {
tracing::warn!(
error = %error,
+9
View File
@@ -296,6 +296,7 @@ Common commands for first-time users:
| `/memory` | Inspect or manage memory when enabled |
| `/mcp` | Configure or inspect MCP server integration |
| `/plugin` | Review and manage disabled-by-default local plugin bundles |
| `/rc` | Hand this exact session to the signed-in Codewhale web app |
Toolbox commands stay searchable when you type them directly: `/models`
fetches live endpoint IDs, `/modeldb` opens the bundled model reference, and
@@ -566,6 +567,14 @@ attached to the project you opened. Press `a` in the picker to show sessions
from every workspace, or run `codewhale sessions` to list all saved sessions
with last-updated timestamps before resuming a specific id.
To continue the exact running session from the web app, type `/rc` or launch
with `codewhale rc`. Approve the one-time code in the system browser. While the
lease is active, the browser owns new prompts and approvals and the terminal is
a readable safety surface; `/rc status` shows ownership, `/rc stop` returns it
to the terminal, and interrupt remains available. A dropped connection keeps
local input locked until the last web lease expires so two controllers never
race.
### What should I do when the model gets confused?
Stop and restate the goal, constraints, and current evidence. If the transcript