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:
@@ -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!(
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"CmdQueueIndexPositive": "インデックスは正の数値である必要があります",
|
||||
"CmdQueueIndexMin": "インデックスは 1 以上である必要があります",
|
||||
"CmdRelayDescription": "新しいスレッド用のセッションリレー(接力)を作成",
|
||||
"CmdRemoteControlDescription": "Codewhaleウェブアカウントからこのセッションを再開",
|
||||
"CmdRenameDescription": "現在のセッションの名前を変更",
|
||||
"CmdRestoreDescription": "ワークスペースを以前のターン前/後スナップショットへロールバック。引数なしで最近のスナップショットを一覧表示。",
|
||||
"CmdRetryDescription": "直前のリクエストを再試行",
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"CmdQueueIndexPositive": "인덱스는 양수여야 합니다",
|
||||
"CmdQueueIndexMin": "인덱스는 1 이상이어야 합니다",
|
||||
"CmdRelayDescription": "새 스레드를 위한 세션 릴레이(接力)를 생성합니다",
|
||||
"CmdRemoteControlDescription": "Codewhale 웹 계정에서 이 세션을 그대로 재개합니다",
|
||||
"CmdRenameDescription": "현재 세션 이름을 바꿉니다",
|
||||
"CmdRestoreDescription": "작업 공간을 이전 턴 전/후 스냅샷으로 되돌립니다. 인자가 없으면 최근 스냅샷 목록을 표시합니다.",
|
||||
"CmdRetryDescription": "마지막 요청을 재시도합니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"CmdQueueIndexPositive": "索引必须为正数",
|
||||
"CmdQueueIndexMin": "索引必须 >= 1",
|
||||
"CmdRelayDescription": "为新线程创建会话接力摘要",
|
||||
"CmdRemoteControlDescription": "从 Codewhale 网页账户继续这个会话",
|
||||
"CmdRenameDescription": "重命名当前会话",
|
||||
"CmdRestoreDescription": "将工作区回滚到此前的轮次前/后快照。不带参数时列出最近的快照。",
|
||||
"CmdRetryDescription": "重试上一次请求",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user