Merge remote-tracking branch 'origin/main' into docs/codewhale-agent-identity-20260828

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-08-28 23:50:05 -07:00
commit b37699142c
31 files changed
+1732 -4

No files matched your search

+26 -1
View File
@@ -61,6 +61,16 @@ jobs:
if: env.HAS_PROVIDER_KEY == 'true'
uses: dtolnay/rust-toolchain@stable
- name: Install native build deps
if: env.HAS_PROVIDER_KEY == 'true'
run: |
for i in 1 2 3; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Cache cargo build
if: env.HAS_PROVIDER_KEY == 'true'
uses: Swatinem/rust-cache@v2
@@ -81,4 +91,19 @@ jobs:
if [ -n "${CODEWHALE_REVIEW_MODEL:-}" ]; then
MODEL_ARGS=(--model "$CODEWHALE_REVIEW_MODEL")
fi
./target/release/codewhale review --pr "$PR_NUMBER" --post "${MODEL_ARGS[@]}"
set +e
OUTPUT=$(./target/release/codewhale review --pr "$PR_NUMBER" --post "${MODEL_ARGS[@]}" 2>&1)
STATUS=$?
set -e
echo "$OUTPUT"
if [ "$STATUS" -eq 0 ]; then
exit 0
fi
# The review is advisory: a provider-side outage (balance, auth,
# rate limit, upstream 5xx) must not block the PR. Real review
# failures still fail the job with the original exit status.
if echo "$OUTPUT" | grep -qE 'LLM error: HTTP (401|402|403|408|429|5[0-9][0-9])'; then
echo "::warning::Codewhale review could not run (provider error; see log above). The PR is not blocked — provider funding/config is founder-gated."
exit 0
fi
exit "$STATUS"
+5
View File
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add `codewhale dispatch` / `/dispatch` so a local session can propose a
Codewhale cloud agent against an explicit `github`, `cnb`, or `gitee` remote.
Confirmation is required; missing credentials fail closed; cloud jobs share
the existing `/jobs` surface as `kind=cloud`. See
[DAYTONA_CLOUD_DISPATCH.md](docs/DAYTONA_CLOUD_DISPATCH.md).
- `/login` reports the Codewhale account session and provider-key next
steps. The internal cloud-agent credential is not user surface: there is
no `auth set-slot`/`auth clear-slot` command, no hint, and no completion
+204
View File
@@ -0,0 +1,204 @@
//! First-class Daytona cloud-agent offload: `codewhale dispatch`.
use std::io::{self, Write};
use std::path::PathBuf;
use anyhow::{Result, bail};
use clap::{Args, ValueEnum};
use codewhale_tui::cloud_dispatch::{
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
format_status, plan_dispatch,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ForgeArg {
Github,
Cnb,
Gitee,
}
impl From<ForgeArg> for Forge {
fn from(value: ForgeArg) -> Self {
match value {
ForgeArg::Github => Forge::Github,
ForgeArg::Cnb => Forge::Cnb,
ForgeArg::Gitee => Forge::Gitee,
}
}
}
#[derive(Debug, Args)]
pub(crate) struct DispatchArgs {
/// Task for the remote agent. Required unless listing or inspecting a job.
#[arg(value_name = "PROMPT")]
prompt: Vec<String>,
/// Forge that should receive the branch and PR: github, cnb, or gitee.
#[arg(long, value_enum)]
remote: Option<ForgeArg>,
/// Branch the remote agent will raise (default: codewhale/cloud-<unix>).
#[arg(long)]
branch: Option<String>,
/// Required to create Daytona spend or push. Without this, only a proposal is written.
#[arg(long)]
confirm: bool,
/// Show remotes and whether Daytona credentials are present (never prints secrets).
#[arg(long)]
status: bool,
/// List first-class cloud jobs (same kind shown by `/jobs`).
#[arg(long)]
list: bool,
/// Inspect one cloud job.
#[arg(long, value_name = "ID")]
show: Option<String>,
/// Cancel one cloud job.
#[arg(long, value_name = "ID")]
cancel: Option<String>,
/// Workspace whose git remotes are classified (default: current directory).
#[arg(long)]
cwd: Option<PathBuf>,
}
pub(crate) fn run(args: DispatchArgs) -> Result<()> {
let mut out = io::stdout().lock();
run_with(args, &mut out)
}
fn run_with<W: Write>(args: DispatchArgs, out: &mut W) -> Result<()> {
if [
args.status,
args.list,
args.show.is_some(),
args.cancel.is_some(),
!args.prompt.is_empty(),
]
.iter()
.filter(|flag| **flag)
.count()
> 1
{
bail!("Use one of: a prompt, --status, --list, --show <id>, or --cancel <id>.");
}
let workspace = args
.cwd
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let remotes = discover_remotes(&workspace);
let credentials = discover_credentials();
let store = CloudJobStore::from_env()?;
if args.status
|| (args.prompt.is_empty() && args.show.is_none() && args.cancel.is_none() && !args.list)
{
writeln!(out, "{}", format_status(&remotes, &credentials))?;
return Ok(());
}
if args.list {
writeln!(out, "{}", format_job_list(&store.list()?))?;
return Ok(());
}
if let Some(id) = args.show.as_deref() {
writeln!(out, "{}", format_job(&store.load(id)?))?;
return Ok(());
}
if let Some(id) = args.cancel.as_deref() {
writeln!(out, "{}", format_job(&cancel_job(&store, id)?))?;
return Ok(());
}
let prompt = args.prompt.join(" ");
if prompt.starts_with("cloud_") && args.confirm && prompt.split_whitespace().count() == 1 {
return write_outcome(
out,
confirm_job(&store, prompt.trim(), &credentials, &LiveDaytonaLauncher)?,
);
}
let plan = plan_dispatch(
&remotes,
&prompt,
args.remote.map(Forge::from),
args.branch.as_deref(),
)?;
let outcome = execute_dispatch(
&store,
plan,
args.confirm,
&credentials,
&LiveDaytonaLauncher,
)?;
write_outcome(out, outcome)
}
fn write_outcome<W: Write>(out: &mut W, outcome: DispatchOutcome) -> Result<()> {
match outcome {
DispatchOutcome::Proposal(job) | DispatchOutcome::Accepted(job) => {
writeln!(out, "{}", format_job(&job))?;
Ok(())
}
DispatchOutcome::Refused(job) => {
writeln!(out, "{}", format_job(&job))?;
bail!("{}", job.note);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Cli, Commands};
use clap::Parser;
fn args(argv: &[&str]) -> DispatchArgs {
let cli = Cli::try_parse_from(argv).unwrap();
let Some(Commands::Dispatch(args)) = cli.command else {
panic!("expected dispatch command");
};
args
}
#[test]
fn parses_the_obvious_dispatch_command() {
let parsed = args(&[
"codewhale",
"dispatch",
"fix",
"the",
"flake",
"--remote",
"github",
]);
assert_eq!(parsed.prompt, ["fix", "the", "flake"]);
assert_eq!(parsed.remote, Some(ForgeArg::Github));
assert!(!parsed.confirm);
assert!(args(&["codewhale", "cloud-agent", "--status"]).status);
assert!(Cli::try_parse_from(["codewhale", "dispatch", "--remote", "gitlab"]).is_err());
}
#[test]
fn status_is_fail_closed_and_never_prints_secrets() {
let temp = tempfile::tempdir().unwrap();
let mut output = Vec::new();
run_with(
DispatchArgs {
prompt: Vec::new(),
remote: None,
branch: None,
confirm: false,
status: true,
list: false,
show: None,
cancel: None,
cwd: Some(temp.path().to_path_buf()),
},
&mut output,
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Codewhale cloud dispatch"));
assert!(!text.contains("Daytona"));
assert!(!text.contains("sk-"));
assert!(!text.contains("Bearer"));
}
}
+5
View File
@@ -3,6 +3,7 @@
mod cloud;
mod config_bundles;
mod credential_handoff;
mod dispatch;
mod metrics;
#[cfg(not(target_env = "ohos"))]
mod update;
@@ -395,6 +396,9 @@ New integrations should prefer `codewhale app-server`.")]
/// Sign in to your Codewhale account and manage account-scoped provider keys.
#[command(visible_alias = "cloud")]
Account(cloud::CloudArgs),
/// Offload a coding agent to Daytona. Never spends or pushes without --confirm.
#[command(visible_alias = "cloud-agent")]
Dispatch(dispatch::DispatchArgs),
/// Run MCP server mode over stdio.
McpServer,
/// Read/write/list config values.
@@ -2081,6 +2085,7 @@ fn run() -> Result<()> {
cloud::reject_inline_api_key(cli.api_key.as_deref())?;
cloud::run(args, cli.profile.as_deref(), &store)
}
Some(Commands::Dispatch(args)) => dispatch::run(args),
Some(Commands::McpServer) => {
// `codewhale serve --mcp` delegates to the TUI and arms there, so
// without this the same user action reported differently depending
+5
View File
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add `codewhale dispatch` / `/dispatch` so a local session can propose a
Codewhale cloud agent against an explicit `github`, `cnb`, or `gitee` remote.
Confirmation is required; missing credentials fail closed; cloud jobs share
the existing `/jobs` surface as `kind=cloud`. See
[DAYTONA_CLOUD_DISPATCH.md](docs/DAYTONA_CLOUD_DISPATCH.md).
- `/login` reports the Codewhale account session and provider-key next
steps. The internal cloud-agent credential is not user surface: there is
no `auth set-slot`/`auth clear-slot` command, no hint, and no completion
+1
View File
@@ -397,6 +397,7 @@
"CmdLspDescription": "Activa o desactiva els diagnòstics LSP",
"CmdShareDescription": "Exporta la sessió actual com a URL web compartible",
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
"CmdDispatchDescription": "Envia un agent cloud de Codewhale per crear una branca i obrir un PR",
"CmdLinksDescription": "Mostra enllaços de Codewhale, la comunitat i els proveïdors",
"CmdLoadDescription": "Carrega una sessió des d'un fitxer",
"CmdLogoutDescription": "Esborra la clau d'API i torna a la configuració",
+1
View File
@@ -397,6 +397,7 @@
"CmdLspDescription": "LSP-Diagnosen ein- oder ausschalten",
"CmdShareDescription": "Aktuelle Sitzung als teilbare Web-URL exportieren",
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
"CmdDispatchDescription": "Codewhale-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
"CmdLoadDescription": "Sitzung aus Datei laden",
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
+1
View File
@@ -400,6 +400,7 @@
"CmdLspDescription": "Toggle LSP diagnostics on or off",
"CmdShareDescription": "Export current session as a shareable web URL",
"CmdJobsDescription": "Inspect and control background Bash jobs",
"CmdDispatchDescription": "Send a Codewhale cloud agent to raise a branch and open a PR",
"CmdLinksDescription": "Show Codewhale, community, and provider links",
"CmdLoadDescription": "Load session from file",
"CmdLogoutDescription": "Clear API key and return to setup",
+1
View File
@@ -400,6 +400,7 @@
"CmdLspDescription": "Alternar diagnóstico LSP encendido o apagado",
"CmdShareDescription": "Exportar la sesión actual como una URL web compartible",
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
"CmdDispatchDescription": "Enviar un agente cloud de Codewhale para crear una rama y abrir un PR",
"CmdLinksDescription": "Mostrar enlaces de Codewhale, la comunidad y los proveedores",
"CmdLoadDescription": "Cargar la sesión desde un archivo",
"CmdLogoutDescription": "Limpiar la clave de API y volver a la configuración",
+1
View File
@@ -397,6 +397,7 @@
"CmdLspDescription": "Activer ou désactiver les diagnostics LSP",
"CmdShareDescription": "Exporter la session en cours en URL web partageable",
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
"CmdDispatchDescription": "Envoyer un agent cloud Codewhale pour créer une branche et ouvrir une PR",
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
"CmdLoadDescription": "Charger une session depuis un fichier",
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
+1
View File
@@ -397,6 +397,7 @@
"CmdLspDescription": "LSP डायग्नोस्टिक्स चालू/बंद करें",
"CmdShareDescription": "वर्तमान सत्र को साझा करने योग्य वेब URL के रूप में एक्सपोर्ट करें",
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Codewhale क्लाउड एजेंट भेजें",
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
+1
View File
@@ -397,6 +397,7 @@
"CmdLspDescription": "Aktifkan atau nonaktifkan diagnostik LSP",
"CmdShareDescription": "Ekspor sesi saat ini sebagai URL web yang dapat dibagikan",
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
"CmdDispatchDescription": "Kirim agen cloud Codewhale untuk membuat branch dan membuka PR",
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
"CmdLoadDescription": "Muat sesi dari file",
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
+1
View File
@@ -400,6 +400,7 @@
"CmdLspDescription": "LSP 診断のオン・オフを切り替え",
"CmdShareDescription": "現在のセッションを共有可能な Web URL としてエクスポート",
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
"CmdDispatchDescription": "Codewhale クラウドエージェントを送り、ブランチと PR を作成",
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
"CmdLoadDescription": "ファイルからセッションを読み込み",
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
+1
View File
@@ -400,6 +400,7 @@
"CmdLspDescription": "LSP 진단을 켜거나 끕니다",
"CmdShareDescription": "현재 세션을 공유 가능한 웹 URL로 내보냅니다",
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
"CmdDispatchDescription": "Codewhale 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
+1
View File
@@ -400,6 +400,7 @@
"CmdLspDescription": "Alternar diagnóstico LSP ligado ou desligado",
"CmdShareDescription": "Exportar a sessão atual como uma URL web compartilhável",
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
"CmdDispatchDescription": "Enviar um agente cloud da Codewhale para criar um branch e abrir um PR",
"CmdLinksDescription": "Exibir links do Codewhale, da comunidade e dos provedores",
"CmdLoadDescription": "Carregar a sessão de um arquivo",
"CmdLogoutDescription": "Limpar a chave de API e voltar à configuração",
+1
View File
@@ -397,6 +397,7 @@
"CmdLspDescription": "Включить или выключить диагностику LSP",
"CmdShareDescription": "Экспортировать текущую сессию как веб-ссылку для общего доступа",
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
"CmdDispatchDescription": "Отправить облачного агента Codewhale, чтобы создать ветку и открыть PR",
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
"CmdLoadDescription": "Загрузить сессию из файла",
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
+1
View File
@@ -397,6 +397,7 @@
"CmdLspDescription": "Увімкнути або вимкнути діагностику LSP",
"CmdShareDescription": "Експортувати поточний сеанс як веб-URL для поширення",
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
"CmdDispatchDescription": "Надіслати хмарного агента Codewhale, щоб створити гілку й відкрити PR",
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
"CmdLoadDescription": "Завантажити сеанс з файлу",
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
+1
View File
@@ -400,6 +400,7 @@
"CmdLspDescription": "Bật hoặc tắt tính năng chẩn đoán LSP",
"CmdShareDescription": "Xuất phiên hiện tại thành một liên kết web có thể chia sẻ",
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
"CmdDispatchDescription": "Gửi tác nhân đám mây Codewhale để tạo nhánh và mở PR",
"CmdLinksDescription": "Hiển thị liên kết Codewhale, cộng đồng và nhà cung cấp",
"CmdLoadDescription": "Tải phiên làm việc từ tệp",
"CmdLogoutDescription": "Xóa khóa API và quay lại bước thiết lập",
+1
View File
@@ -400,6 +400,7 @@
"CmdLspDescription": "切换 LSP 诊断的开启或关闭",
"CmdShareDescription": "将当前会话导出为可共享的 Web URL",
"CmdJobsDescription": "查看并管理后台 shell 作业",
"CmdDispatchDescription": "派遣 Codewhale 云端代理以创建分支并打开拉取请求",
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
"CmdLoadDescription": "从文件加载会话",
"CmdLogoutDescription": "清除 API 密钥并返回设置",
+1
View File
@@ -289,6 +289,7 @@
"CmdHotbarDescription": "開啟 Hotbar 設定",
"CmdInitDescription": "為項目生成 AGENTS.md",
"CmdJobsDescription": "檢視並管理後台 shell 作業",
"CmdDispatchDescription": "派遣 Codewhale 雲端代理以建立分支並開啟拉取請求",
"CmdLaneDescription": "檢視與控制持久化的 Lane(執行中的工作流程)",
"CmdLinksDescription": "顯示 Codewhale、社群與提供商鏈接",
"CmdLoadDescription": "從檔案載入工作階段",
File diff suppressed because it is too large. Load diff
+1
View File
@@ -181,6 +181,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
"cmd_hotbar_description" => MessageId::CmdHotbarDescription,
"cmd_init_description" => MessageId::CmdInitDescription,
"cmd_jobs_description" => MessageId::CmdJobsDescription,
"cmd_dispatch_description" => MessageId::CmdDispatchDescription,
"cmd_lane_description" => MessageId::CmdLaneDescription,
"cmd_links_description" => MessageId::CmdLinksDescription,
"cmd_load_description" => MessageId::CmdLoadDescription,
@@ -0,0 +1,217 @@
//! `/dispatch` — first-class Codewhale cloud-agent offload.
use codewhale_command_contract::facets::CommandWorkspaceContext;
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use crate::cloud_dispatch::{
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
format_status, plan_dispatch,
};
use crate::commands::CommandResult;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "dispatch",
aliases: &["cloud-agent", "cloud-dispatch"],
usage: "/dispatch [list|show <id>|confirm <id>|cancel <id>|<prompt> [--remote github|cnb|gitee]]",
description_key: "cmd_dispatch_description",
};
pub(in crate::commands) struct DispatchCmd;
impl RegisterCommand<CommandResult> for DispatchCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual(dispatch_contextual)
}
}
fn dispatch_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
let mut parts = contexts.into_parts();
let workspace = parts.workspace.as_deref_mut().expect("workspace facet");
dispatch(workspace, arg)
}
fn dispatch(workspace: &mut dyn CommandWorkspaceContext, args: Option<&str>) -> CommandResult {
let raw = args.unwrap_or("").trim();
let store = match CloudJobStore::from_env() {
Ok(store) => store,
Err(error) => return CommandResult::error(error.to_string()),
};
if raw.is_empty() {
return CommandResult::message(format_status(
&discover_remotes(&workspace.workspace()),
&discover_credentials(),
));
}
let mut parts = raw.splitn(2, char::is_whitespace);
let verb = parts.next().unwrap_or("").to_ascii_lowercase();
let rest = parts.next().map(str::trim).unwrap_or("");
match verb.as_str() {
"list" => match store.list() {
Ok(jobs) => CommandResult::message(format_job_list(&jobs)),
Err(error) => CommandResult::error(error.to_string()),
},
"show" | "inspect" => {
if rest.is_empty() {
return CommandResult::error("Usage: /dispatch show <id>");
}
match store.load(rest) {
Ok(job) => CommandResult::message(format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
"confirm" => {
if rest.is_empty() {
return CommandResult::error("Usage: /dispatch confirm <id>");
}
match confirm_job(&store, rest, &discover_credentials(), &LiveDaytonaLauncher) {
Ok(outcome) => CommandResult::message(outcome_message(&outcome)),
Err(error) => CommandResult::error(error.to_string()),
}
}
"cancel" | "kill" | "stop" => {
if rest.is_empty() {
return CommandResult::error("Usage: /dispatch cancel <id>");
}
match cancel_job(&store, rest) {
Ok(job) => CommandResult::message(format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
_ => propose_or_run(workspace, &store, raw, false),
}
}
fn propose_or_run(
workspace: &mut dyn CommandWorkspaceContext,
store: &CloudJobStore,
raw: &str,
confirm: bool,
) -> CommandResult {
let (prompt, requested) = match split_prompt_and_remote(raw) {
Ok(parsed) => parsed,
Err(error) => return CommandResult::error(error),
};
let remotes = discover_remotes(&workspace.workspace());
let plan = match plan_dispatch(&remotes, &prompt, requested, None) {
Ok(plan) => plan,
Err(error) => return CommandResult::error(error.to_string()),
};
match execute_dispatch(
store,
plan,
confirm,
&discover_credentials(),
&LiveDaytonaLauncher,
) {
Ok(outcome) => CommandResult::message(outcome_message(&outcome)),
Err(error) => CommandResult::error(error.to_string()),
}
}
fn split_prompt_and_remote(raw: &str) -> Result<(String, Option<Forge>), String> {
let mut remote = None;
let mut prompt_parts = Vec::new();
let mut tokens = raw.split_whitespace().peekable();
while let Some(token) = tokens.next() {
if token == "--remote" {
let Some(value) = tokens.next() else {
return Err("Usage: /dispatch <prompt> --remote github|cnb|gitee".to_string());
};
remote = Some(
Forge::parse(value)
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
);
continue;
}
if let Some(value) = token.strip_prefix("--remote=") {
remote = Some(
Forge::parse(value)
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
);
continue;
}
prompt_parts.push(token);
}
let prompt = prompt_parts.join(" ");
if prompt.is_empty() {
return Err("Usage: /dispatch <prompt> [--remote github|cnb|gitee]".to_string());
}
Ok((prompt, remote))
}
fn outcome_message(outcome: &DispatchOutcome) -> String {
match outcome {
DispatchOutcome::Proposal(job)
| DispatchOutcome::Refused(job)
| DispatchOutcome::Accepted(job) => format_job(job),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
struct FakeWorkspace(PathBuf);
impl CommandWorkspaceContext for FakeWorkspace {
fn workspace(&self) -> PathBuf {
self.0.clone()
}
fn work_state_snapshot(&self) -> Result<Option<String>, String> {
Ok(None)
}
fn operation_digest(&mut self) -> Result<String, String> {
Ok("digest".to_string())
}
}
#[test]
fn parses_prompt_and_explicit_remote() {
let (prompt, remote) = split_prompt_and_remote("fix the flake --remote cnb").unwrap();
assert_eq!(prompt, "fix the flake");
assert_eq!(remote, Some(Forge::Cnb));
assert!(split_prompt_and_remote("--remote gitee").is_err());
assert_eq!(
split_prompt_and_remote("open pr --remote=github")
.unwrap()
.1,
Some(Forge::Github)
);
}
#[test]
fn handler_is_contextual_and_argument_aware() {
assert!(matches!(
DispatchCmd::handler(),
CommandHandler::Contextual(_)
));
assert_eq!(
DispatchCmd::info().description_key,
"cmd_dispatch_description"
);
assert_eq!(
DispatchCmd::info().aliases,
&["cloud-agent", "cloud-dispatch"]
);
assert!(DispatchCmd::info().usage.starts_with("/dispatch"));
}
#[test]
fn bare_dispatch_is_a_status_card_not_a_silent_launch() {
let mut workspace = FakeWorkspace(PathBuf::from("."));
let result = dispatch(&mut workspace, None);
assert!(!result.is_error, "{result:?}");
let message = result.message.expect("status message");
assert!(message.contains("Cloud agents"));
assert!(message.contains("fails closed") || message.contains("ready"));
assert!(result.action.is_none());
}
}
@@ -43,6 +43,7 @@ fn jobs(args: Option<&str>) -> CommandResult {
match action.as_str() {
"list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
"show" | "inspect" => match id {
Some(id) if id.starts_with("cloud_") => show_cloud_job(id),
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
id: id.to_string(),
})),
@@ -74,6 +75,7 @@ fn jobs(args: Option<&str>) -> CommandResult {
None => CommandResult::error("Usage: /jobs close-stdin <id>"),
},
"cancel" | "kill" | "stop" => match id {
Some(id) if id.starts_with("cloud_") => cancel_cloud_job(id),
Some(id) if is_cancel_all_token(id) => {
CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll))
}
@@ -93,6 +95,22 @@ fn jobs(args: Option<&str>) -> CommandResult {
}
}
fn show_cloud_job(id: &str) -> CommandResult {
match crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.load(id)) {
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
fn cancel_cloud_job(id: &str) -> CommandResult {
match crate::cloud_dispatch::CloudJobStore::from_env()
.and_then(|store| crate::cloud_dispatch::cancel_job(&store, id))
{
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -118,6 +136,10 @@ mod tests {
Some(AppAction::ShellJob(ShellJobAction::CancelAll))
));
let cloud = jobs(Some("show cloud_deadbeef"));
assert!(cloud.action.is_none());
assert!(cloud.message.is_some() || cloud.is_error);
let cancel_all_spaced = jobs(Some("cancel all"));
assert!(matches!(
cancel_all_spaced.action,
@@ -3,6 +3,7 @@
mod attachment;
mod automation;
mod dispatch;
mod jobs;
mod mcp;
mod network;
@@ -30,6 +31,10 @@ impl CommandGroup for UtilityCommands {
Box::new(
ContextualCommand::from_contract::<jobs::JobsCmd>().expect("jobs registration"),
),
Box::new(
ContextualCommand::from_contract::<dispatch::DispatchCmd>()
.expect("dispatch registration"),
),
Box::new(ContextualCommand::from_contract::<mcp::McpCmd>().expect("mcp registration"),),
Box::new(
ContextualCommand::from_contract::<network::NetworkCmd>()
+5 -2
View File
@@ -1112,6 +1112,7 @@ mod tests {
fn flagship_orchestration_and_workspace_commands_are_visible_at_the_palette_root() {
for name in [
"auto",
"dispatch",
"goal",
"hooks",
"tokens",
@@ -1892,11 +1893,13 @@ mod tests {
// FEAT-015 shipped no production contextual command, so the assertion
// below used to exclude nothing. FEAT-018 migrates the utility group;
// the remaining non-fixture commands must still use the legacy
// concrete-App path. The seven utility entries are asserted separately
// by the FEAT-018 public-dispatch and inventory tests (Phase 6).
// concrete-App path. The migrated utility entries (the FEAT-018 seven
// plus `/dispatch`, which joins the same portable path) are asserted
// separately by the FEAT-018 public-dispatch and inventory tests.
const FEAT_018_UTILITY: &[&str] = &[
"attach",
"automation",
"dispatch",
"jobs",
"mcp",
"network",
+1
View File
@@ -29,6 +29,7 @@ mod auto_reasoning;
mod automation_manager;
mod child_env;
mod client;
pub mod cloud_dispatch;
mod codex_model_cache;
mod command_safety;
mod commands;
+2
View File
@@ -432,6 +432,7 @@ pub enum MessageId {
GoalContinuationStopped,
CmdInitDescription,
CmdJobsDescription,
CmdDispatchDescription,
CmdLinksDescription,
CmdLoadDescription,
CmdLogoutDescription,
@@ -2255,6 +2256,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdAgentDescription,
MessageId::CmdInitDescription,
MessageId::CmdJobsDescription,
MessageId::CmdDispatchDescription,
MessageId::CmdLinksDescription,
MessageId::CmdLoadDescription,
MessageId::CmdLogoutDescription,
+9 -1
View File
@@ -678,7 +678,15 @@ pub(crate) fn handle_shell_job_action(app: &mut App, action: crate::tui::app::Sh
match action {
crate::tui::app::ShellJobAction::List => {
let jobs = manager.list_jobs_for_session(&active_session_id);
add_shell_job_message(app, format_shell_job_list(&jobs));
let mut text = format_shell_job_list(&jobs);
if let Ok(cloud) =
crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.list())
&& !cloud.is_empty()
{
text.push_str("\n\n");
text.push_str(&crate::cloud_dispatch::format_job_list(&cloud));
}
add_shell_job_message(app, text);
}
crate::tui::app::ShellJobAction::Show { id } => {
match manager.inspect_job_for_session(&active_session_id, &id) {
+92
View File
@@ -0,0 +1,92 @@
# Daytona cloud-agent dispatch
Local `cw` / Codewhale can offload a coding agent to Daytona the way Cursor
sends a cloud agent: the remote job raises a branch and is intended to open a
PR against an explicit forge. Local stays responsive; spend and push never
happen silently.
## One obvious offload
```sh
codewhale dispatch "open a PR that fixes the flake" --remote github
codewhale dispatch --confirm cloud_<id>
```
Same action in the TUI:
```
/dispatch open a PR that fixes the flake --remote github
/dispatch confirm cloud_<id>
```
`codewhale cloud-agent` and `/cloud-agent` are aliases. `--confirm` /
`/dispatch confirm` is required. A proposal is written first; nothing creates a
Daytona sandbox or pushes a branch until that confirmation.
Cloud jobs are first-class on the existing jobs surface (`kind=cloud`):
```
/jobs list
/dispatch list
/dispatch show <id>
/dispatch cancel <id>
codewhale dispatch --list
```
## Remotes
Forges are explicit: `github`, `cnb`, `gitee`.
CWC already treats a remote *named* `github` as authoritative GitHub and
`origin` as the CNB mirror when that URL is `cnb.cool`. Codewhale uses the same
rule:
| Remote name | URL host | Forge |
| --- | --- | --- |
| `github` | any | `github` |
| `cnb` | any | `cnb` |
| `gitee` | any | `gitee` |
| `origin` or other | `github.com` | `github` |
| `origin` or other | `cnb.cool` | `cnb` |
| `origin` or other | `gitee.com` | `gitee` |
If more than one forge is present, pass `--remote` / `--remote` on `/dispatch`.
Do not assume `origin` is GitHub.
## Enable Daytona (fail-closed)
This slice does **not** fake a successful remote PR. Without credentials,
dispatch refuses and records a `refused` cloud job.
1. Create an API key at [app.daytona.io/dashboard/keys](https://app.daytona.io/dashboard/keys).
2. Export it in the process environment (never commit it, never put it in
`models.toml` or `config.toml`):
```sh
export DAYTONA_API_KEY=...
# optional
export DAYTONA_API_URL=https://app.daytona.io/api
```
3. Or store the same key in the Codewhale secret slot `daytona` (OS keyring /
`$CODEWHALE_HOME` secrets file). The CWC alias `CWC_DAYTONA_TOKEN` is also
accepted so an already-configured control-plane shell works.
`daytona` CLI being installed is **not** a credential. `codewhale dispatch
--status` / bare `/dispatch` report CLI presence separately.
## Confirmation and fail-closed rules
- No `--confirm` / `/dispatch confirm`: write a `proposed` job, exit success,
do not call Daytona, do not push.
- Confirm + no credentials: write a `refused` job, exit failure, no sandbox.
- Confirm + credentials: create a Daytona sandbox labeled with the job id and
forge. This slice does **not** claim a GitHub/CNB/Gitee PR URL. A missing
forge token fails closed the same way — it does not invent a PR.
## Leftover
- Live watch / log tail of a running sandbox.
- Cancel that tears down a paid Daytona sandbox.
- Auto-decide heuristics (Codewhale may propose; it must not confirm itself).
- The remote agent runner that actually raises the branch and opens the PR.
+3
View File
@@ -295,6 +295,9 @@ Operate keeps that direct tool surface and its approval, sandbox, shell,
ask-rule, and repository protections. Its difference is orchestration emphasis:
Codewhale prefers Fleet workers for independent, parallel, background, or
long-running work, while small or tightly coupled work can remain in the parent.
Heavy work can also be proposed to a Daytona cloud agent with `codewhale
dispatch` or `/dispatch` (explicit confirmation; remotes are `github` / `cnb` /
`gitee`). See [DAYTONA_CLOUD_DISPATCH.md](DAYTONA_CLOUD_DISPATCH.md).
For trusted workspaces where you intentionally want actions to proceed without
approval prompts, select the Full Access permission posture with `Shift+Tab`.