diff --git a/site/src/content/docs/features/agent-scheduling.mdx b/site/src/content/docs/features/agent-scheduling.mdx index 7fa56f529..80cbb1f4b 100644 --- a/site/src/content/docs/features/agent-scheduling.mdx +++ b/site/src/content/docs/features/agent-scheduling.mdx @@ -7,15 +7,17 @@ Claudette can persist agent wakeups and recurring routines in its app database. ## Agent Tools -When the built-in Claudette MCP bridge is available, agents can use these native tools: - -| Tool | Purpose | -|---|---| -| `ScheduleWakeup` | Schedule a one-shot wakeup with `delaySeconds` or `fireAt`, plus a prompt and optional reason. | -| `CronCreate` | Create a routine from a standard 5-field cron expression in local time. | -| `CronList` | List persisted wakeups and routines. | -| `CronDelete` | Delete a routine by id or name. | -| `Monitor` | Subscribe to future output lines from a background Bash task instead of polling. | +Agents can schedule their own wakeups and routines through native tools: + +| Tool | Purpose | Backends | +|---|---|---| +| `ScheduleWakeup` | Schedule a one-shot wakeup with `delaySeconds` or `fireAt`, plus a prompt and optional reason. | Claude, Codex, Pi | +| `CronCreate` | Create a routine from a standard 5-field cron expression in local time. | Claude, Codex, Pi | +| `CronList` | List persisted wakeups and routines. | Claude, Codex, Pi | +| `CronDelete` | Delete a routine by id or name. | Claude, Codex, Pi | +| `Monitor` | Subscribe to future output lines from a background Bash task instead of polling. | Claude, Codex | + +Claude and Codex agents reach these tools over the built-in Claudette MCP bridge. The Pi backend has no MCP bridge, so its scheduling tools are registered natively in the Pi sidecar — `Monitor` is unavailable there. Scheduling stays available regardless of the **Agent Attachments** plugin toggle. Wakeups and routines survive app restarts. If a fire time passes while Claudette is closed, the scheduler fires the overdue task when the app starts again. diff --git a/src-pi-harness/src/main.ts b/src-pi-harness/src/main.ts index 840509b3c..a215287f2 100644 --- a/src-pi-harness/src/main.ts +++ b/src-pi-harness/src/main.ts @@ -39,12 +39,29 @@ type PendingTool = { resolve: (approved: boolean) => void; }; +// Result of a `host_tool` round-trip — mirrors the Rust `BridgeResponse` +// shape the Claudette host writes back in `host_tool_result`. +type HostToolResult = { + ok: boolean; + message?: string; + data?: unknown; + error?: string; +}; + +type PendingHostTool = { + resolve: (result: HostToolResult) => void; + reject: (error: Error) => void; +}; + type HarnessState = { cwd: string; session?: AgentSession; authStorage: AuthStorage; modelRegistry: ModelRegistry; pendingTools: Map; + // In-flight `host_tool` round-trips, keyed by the originating tool + // call id. Resolved by a matching `host_tool_result` from the host. + pendingHostTools: Map; activeTurnStartedAt?: number; // True when Claudette's permission level is `full` — i.e. the user // ran `/permissions full` and Claudette's tools-for-level resolver @@ -66,6 +83,7 @@ const state: HarnessState = { authStorage: AuthStorage.create(), modelRegistry: ModelRegistry.create(AuthStorage.create()), pendingTools: new Map(), + pendingHostTools: new Map(), bypassPermissions: false, }; @@ -280,6 +298,36 @@ function textResult(text: string, details: Record = {}) { }; } +/** Round-trip a request to the Claudette host and await its result. + * Pi has no MCP bridge, so this generic `host_tool` channel is how a + * native sidecar tool reaches host-only services (currently the + * `agent_scheduled_tasks` DB). The channel is intentionally not + * scheduling-specific — a future user Pi extension that needs host + * services can reuse it. `toolCallId` doubles as the correlation id: + * it is unique per tool call and a scheduling tool never also runs an + * `approval()` round-trip, so the two pending maps can't collide. */ +function hostTool(toolCallId: string, name: string, args: unknown): Promise { + send({ type: "host_tool", requestId: toolCallId, name, args }); + return new Promise((resolve, reject) => { + state.pendingHostTools.set(toolCallId, { resolve, reject }); + }); +} + +/** Render a `HostToolResult` as a Pi tool result. A failed host call + * throws so the SDK marks the tool call `isError`. */ +function hostToolResult(result: HostToolResult) { + if (!result.ok) { + throw new Error(result.error ?? "Scheduling request failed."); + } + const text = result.message ?? "Done."; + if (result.data === undefined || result.data === null) { + return textResult(text); + } + return textResult(`${text}\n${JSON.stringify(result.data, null, 2)}`, { + data: result.data, + }); +} + function buildTools(cwd: string, enabledTools: readonly string[]): ToolDefinition[] { const enabled = new Set(enabledTools); const tools = [ @@ -503,6 +551,58 @@ function buildTools(cwd: string, enabledTools: readonly string[]): ToolDefinitio return textResult(`Edited ${path}`, { path }); }, }), + // Native scheduling tools. These carry no local side effect — each + // `execute` round-trips through `hostTool` to the Claudette host, + // which writes the `agent_scheduled_tasks` DB and re-enters the + // chat when the task is due. Parameter keys mirror the Claudette + // MCP server's scheduling tools so the surfaces stay interchangeable. + defineTool({ + name: "ScheduleWakeup", + label: "Schedule wakeup", + description: + "Schedule a one-shot wakeup that re-enters this chat later with a prompt. " + + "Provide either delaySeconds or fireAt (an RFC3339 timestamp).", + parameters: Type.Object({ + prompt: Type.String(), + delaySeconds: Type.Optional(Type.Number()), + fireAt: Type.Optional(Type.String()), + reason: Type.Optional(Type.String()), + }), + execute: async (toolCallId, params) => + hostToolResult(await hostTool(toolCallId, "ScheduleWakeup", params)), + }), + defineTool({ + name: "CronCreate", + label: "Create routine", + description: + "Create a scheduled routine from a standard 5-field cron expression in local time.", + parameters: Type.Object({ + cron: Type.String(), + prompt: Type.String(), + name: Type.Optional(Type.String()), + recurring: Type.Optional(Type.Boolean()), + }), + execute: async (toolCallId, params) => + hostToolResult(await hostTool(toolCallId, "CronCreate", params)), + }), + defineTool({ + name: "CronList", + label: "List routines", + description: "List scheduled wakeups and routines for this chat session.", + parameters: Type.Object({}), + execute: async (toolCallId, params) => + hostToolResult(await hostTool(toolCallId, "CronList", params)), + }), + defineTool({ + name: "CronDelete", + label: "Delete routine", + description: "Delete a scheduled wakeup or routine by its id or name.", + parameters: Type.Object({ + id: Type.String(), + }), + execute: async (toolCallId, params) => + hostToolResult(await hostTool(toolCallId, "CronDelete", params)), + }), ]; return tools.filter((tool) => enabled.has(tool.name)); } @@ -734,10 +834,18 @@ async function grepFallback(root: string, query: string): Promise { return matches.join("\n"); } +// Native scheduling tools — always enabled, no approval round-trip, +// present in every Pi session regardless of Claudette's permission +// level (they only schedule prompts; they mutate nothing locally). +const SCHEDULING_TOOLS = ["ScheduleWakeup", "CronCreate", "CronList", "CronDelete"]; + function mapPermissionTools(value: unknown): { tools: string[]; bypass: boolean } { const tools = asStringArray(value); if (tools.includes("*")) { - return { tools: ["read", "ls", "find", "grep", "bash", "write", "edit"], bypass: true }; + return { + tools: ["read", "ls", "find", "grep", "bash", "write", "edit", ...SCHEDULING_TOOLS], + bypass: true, + }; } const out = new Set(); for (const tool of tools) { @@ -750,6 +858,7 @@ function mapPermissionTools(value: unknown): { tools: string[]; bypass: boolean if (normalized === "bash") out.add("bash"); } out.add("ls"); + for (const name of SCHEDULING_TOOLS) out.add(name); return { tools: [...out], bypass: false }; } @@ -1434,6 +1543,12 @@ async function handle(message: RequestMessage): Promise { pending.resolve(false); } state.pendingTools.clear(); + // Likewise release in-flight host-tool round-trips so a + // scheduling tool's `execute()` doesn't dangle past the abort. + for (const pending of state.pendingHostTools.values()) { + pending.reject(new Error("aborted")); + } + state.pendingHostTools.clear(); await state.session?.abort(); respond(message.id, message.type, true); break; @@ -1508,6 +1623,20 @@ async function handle(message: RequestMessage): Promise { respond(message.id, message.type, true); break; } + case "host_tool_result": { + // Result of a `host_tool` round-trip. A notification, not a + // request — it carries no `id`, so we send no `response` back. + const requestId = asString(message.requestId); + const pending = requestId ? state.pendingHostTools.get(requestId) : undefined; + if (requestId) state.pendingHostTools.delete(requestId); + pending?.resolve({ + ok: message.ok === true, + message: typeof message.message === "string" ? message.message : undefined, + data: message.data, + error: typeof message.error === "string" ? message.error : undefined, + }); + break; + } case "dispose": state.session?.dispose(); state.session = undefined; diff --git a/src-tauri/src/agent_mcp_sink.rs b/src-tauri/src/agent_mcp_sink.rs index 75e2c2559..fce1b3458 100644 --- a/src-tauri/src/agent_mcp_sink.rs +++ b/src-tauri/src/agent_mcp_sink.rs @@ -107,6 +107,30 @@ async fn handle_payload( media_type, caption, } => { + // The Claudette MCP server is now injected unconditionally so + // its always-on scheduling tools reach every agent (see the + // wiring in commands/chat/send.rs + remote_control.rs). The + // "Agent Attachments" plugin toggle therefore can't gate the + // server's mere presence anymore — it has to gate the + // send_to_user call itself, here. Without this check, + // disabling the plugin would still let attachments through, + // breaking the Settings contract that turning it off removes + // the tool. Reusing the same `is_builtin_plugin_enabled` read + // the system-prompt nudge already consults. + let db_for_gate = match Database::open(&db_path) { + Ok(db) => db, + Err(err) => { + return BridgeResponse::err(format!("open db: {err}")); + } + }; + if !claudette::agent_mcp::is_builtin_plugin_enabled(&db_for_gate, "send_to_user") { + return BridgeResponse::err( + "The Agent Attachments plugin is disabled. Enable it in Settings → \ + Plugins to deliver files inline." + .to_string(), + ); + } + drop(db_for_gate); send_attachment( app, db_path, @@ -189,7 +213,19 @@ fn schedule_wakeup( Ok(db) => db, Err(err) => return BridgeResponse::err(format!("open db: {err}")), }; - match db.create_agent_wakeup(&chat_session_id, fire_at, &prompt, reason.as_deref()) { + // Agent-callable scheduling doesn't pin a backend — the cron inherits + // the global default when it fires. Backend pinning is a frontend + // concern (toolbar choice via `/loop` / `/schedule`); the agent itself + // is already running on a backend and doesn't get to choose for the + // fired turn. + match db.create_agent_wakeup( + &chat_session_id, + fire_at, + &prompt, + reason.as_deref(), + None, + None, + ) { Ok(task) => { app.state::().scheduler_notify.notify_waiters(); BridgeResponse::data( @@ -220,6 +256,8 @@ fn create_cron( &cron_expr, &prompt, recurring, + None, + None, ) { Ok(task) => { app.state::().scheduler_notify.notify_waiters(); diff --git a/src-tauri/src/commands/chat/remote_control.rs b/src-tauri/src/commands/chat/remote_control.rs index 5548b9abb..4aa4ec3ff 100644 --- a/src-tauri/src/commands/chat/remote_control.rs +++ b/src-tauri/src/commands/chat/remote_control.rs @@ -20,10 +20,7 @@ use crate::state::{ AgentSessionState, AppState, ClaudeRemoteControlLifecycle, ClaudeRemoteControlStatus, }; -use super::{ - AgentStreamPayload, build_agent_hook_bridge, now_iso, start_bridge_and_inject_mcp, - start_chat_bridge, -}; +use super::{AgentStreamPayload, build_agent_hook_bridge, now_iso, start_bridge_and_inject_mcp}; #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -480,13 +477,18 @@ async fn ensure_persistent_session_for_remote_control( }); from_config.or_else(|| repo.as_ref().and_then(|r| r.custom_instructions.clone())) }; - let nudge = send_to_user_enabled.then_some(claudette::agent_mcp::SYSTEM_PROMPT_NUDGE); + // Scheduling/Monitor nudge is always on (the MCP server is injected + // unconditionally). `send_to_user` nudge is gated on the Agent + // Attachments plugin toggle. Mirrors the spawn path in + // `commands/chat/send.rs` so the remote-control entry point stays in + // lockstep with normal chat spawns. + let nudge_owned = claudette::agent_mcp::mcp_system_prompt_nudge(send_to_user_enabled); // Remote-control sessions always launch through Claude CLI, so the // Claude-Code MCP rule block applies (AskUserQuestion / ExitPlanMode // exist via the Claudette MCP bridge). let custom_instructions = claudette::global_prompt::compose_system_prompt( instructions.as_deref(), - nudge, + nudge_owned.as_deref(), Some(claudette::agent_mcp::CLAUDE_CODE_MCP_RULES), ); let level = launch_options.permission_level.as_deref().unwrap_or("full"); @@ -569,7 +571,13 @@ async fn ensure_persistent_session_for_remote_control( hook_bridge: None, extra_claude_flags, }; - let bridge = if send_to_user_enabled { + // Inject the Claudette MCP server unconditionally — it carries the + // always-on scheduling tools (ScheduleWakeup / Cron*) and `Monitor`. + // The `send_to_user_enabled` toggle gates only that tool's nudge + // (above) and the call itself (in `agent_mcp_sink::handle_payload`), + // not the whole server. Same wiring as the spawn / respawn blocks in + // `chat::send::send_chat_message`. + let bridge = { let (bridge, mcp_with_claudette) = start_bridge_and_inject_mcp( app, &state.db_path, @@ -580,8 +588,6 @@ async fn ensure_persistent_session_for_remote_control( .await?; agent_settings.mcp_config = mcp_with_claudette; bridge - } else { - start_chat_bridge(app, &state.db_path, &workspace_id, &chat_session_id).await? }; agent_settings.hook_bridge = Some(build_agent_hook_bridge(&bridge)?); diff --git a/src-tauri/src/commands/chat/send.rs b/src-tauri/src/commands/chat/send.rs index 579b1697c..649f76ccf 100644 --- a/src-tauri/src/commands/chat/send.rs +++ b/src-tauri/src/commands/chat/send.rs @@ -37,7 +37,7 @@ use super::naming::{try_auto_rename, try_generate_session_name}; use super::{ ATTENTION_NOTIFY_DELAY_MS, AgentStreamPayload, AttachmentInput, AttachmentResponse, ChatHistoryPage, build_agent_hook_bridge, fire_completion_notification, now_iso, - start_bridge_and_inject_mcp, start_chat_bridge, + start_bridge_and_inject_mcp, }; mod background_tasks; @@ -1468,26 +1468,32 @@ pub async fn send_chat_message( let send_to_user_enabled = claudette::agent_mcp::is_builtin_plugin_enabled(&db, "send_to_user"); // Compose the system prompt for fresh spawns: bundled global prompt → - // MCP nudge (so the model reaches for `mcp__claudette__send_to_user` - // when asked to deliver a file) → per-repo instructions. Resume turns - // reuse the persistent CLI process and never re-pass the prompt. - let nudge = send_to_user_enabled.then_some(claudette::agent_mcp::SYSTEM_PROMPT_NUDGE); + // MCP nudge → per-repo instructions. Resume turns reuse the persistent + // CLI process and never re-pass the prompt. + // + // `mcp_system_prompt_nudge` always includes the scheduling/Monitor + // guidance (those tools are MCP-served via the unconditional bridge + // injection above) and only appends the `send_to_user` guidance when + // the Agent Attachments plugin is enabled. Splitting these keeps the + // toggle gating only the tool it owns. + let nudge_owned = claudette::agent_mcp::mcp_system_prompt_nudge(send_to_user_enabled); let custom_instructions = claudette::global_prompt::compose_system_prompt( session.custom_instructions.as_deref(), - nudge, + nudge_owned.as_deref(), // Claude CLI exposes `AskUserQuestion` / `ExitPlanMode` via the // Claudette MCP bridge, so those rules apply here. Some(claudette::agent_mcp::CLAUDE_CODE_MCP_RULES), ); - // Pi runs without the Claudette MCP bridge, so we strip both the - // send_to_user nudge *and* the AskUserQuestion / ExitPlanMode - // rules. Pointing a qwen / llama / GPT model at MCP tools that - // aren't registered with its runtime confuses the model's tool - // self-model and is part of why "what LLM are you?" used to come - // back with "Claude Code agent" answers on Pi sessions. + // Pi runs without the Claudette MCP bridge, so we strip the + // send_to_user nudge and the AskUserQuestion / ExitPlanMode rules: + // those name MCP tools the Pi sidecar doesn't register, and + // pointing a qwen / llama / GPT model at tools that aren't in its + // runtime confuses the model's tool self-model. Pi *does* get the + // native scheduling tools (registered directly in the sidecar), so + // the send_to_user slot carries the MCP-free PI_SCHEDULING_NUDGE. let pi_custom_instructions = claudette::global_prompt::compose_system_prompt( session.custom_instructions.as_deref(), - None, + Some(claudette::agent_mcp::PI_SCHEDULING_NUDGE), None, ); session.turn_count += 1; @@ -1965,6 +1971,17 @@ pub async fn send_chat_message( let pi_custom_instructions_for_persistent = pi_custom_instructions.clone(); #[cfg(not(feature = "pi-sdk"))] let _ = &pi_custom_instructions; + // Pi has no MCP server, so its native scheduling tools round-trip + // `host_tool` messages straight through this `ChatBridgeSink` — the + // same sink Claude/Codex reach over their MCP socket. + #[cfg(feature = "pi-sdk")] + let pi_sink_for_persistent: Arc = + Arc::new(crate::agent_mcp_sink::ChatBridgeSink { + app: app.clone(), + db_path: state.db_path.clone(), + workspace_id: workspace_id.clone(), + chat_session_id: chat_session_id.clone(), + }); let start_persistent = move |worktree: String, sid: String, is_resume: bool, @@ -1978,6 +1995,8 @@ pub async fn send_chat_message( let pi_sessions_root = pi_sessions_root.clone(); #[cfg(feature = "pi-sdk")] let pi_instructions = pi_custom_instructions_for_persistent.clone(); + #[cfg(feature = "pi-sdk")] + let pi_sink = pi_sink_for_persistent.clone(); async move { // Note: do NOT route the error through `crate::missing_cli::handle_err` // here. The caller's resume-fallback arm needs to inspect the raw @@ -2110,6 +2129,7 @@ pub async fn send_chat_message( .clone(), pi_provider_env: crate::commands::agent_backends::pi_auth::pi_local_secret_env()?, + sink: Some(pi_sink), }, ) .await?; @@ -2161,22 +2181,10 @@ pub async fn send_chat_message( let mut respawn_settings = agent_settings.clone(); let bridge = match respawn_settings.backend_runtime.harness { - AgentBackendRuntimeHarness::ClaudeCode => Some(if send_to_user_enabled { - let (b, mcp_with_claudette) = start_bridge_and_inject_mcp( - &app, - &state.db_path, - &workspace_id, - &chat_session_id, - agent_settings.mcp_config.clone(), - ) - .await?; - respawn_settings.mcp_config = mcp_with_claudette; - b - } else { - start_chat_bridge(&app, &state.db_path, &workspace_id, &chat_session_id) - .await? - }), - AgentBackendRuntimeHarness::CodexAppServer if send_to_user_enabled => { + // Injected unconditionally for the same reason as the + // spawn path above — scheduling tools ride this server. + AgentBackendRuntimeHarness::ClaudeCode + | AgentBackendRuntimeHarness::CodexAppServer => { let (b, mcp_with_claudette) = start_bridge_and_inject_mcp( &app, &state.db_path, @@ -2188,7 +2196,6 @@ pub async fn send_chat_message( respawn_settings.mcp_config = mcp_with_claudette; Some(b) } - AgentBackendRuntimeHarness::CodexAppServer => None, #[cfg(feature = "pi-sdk")] AgentBackendRuntimeHarness::PiSdk => None, }; @@ -2343,27 +2350,23 @@ pub async fn send_chat_message( drop(agents); // Start the agent-MCP bridge and merge the synthetic `claudette` - // server entry into the spawn-time `--mcp-config` JSON when the - // built-in `send_to_user` plugin is enabled. The bridge is stored - // on the session below so it lives exactly as long as the - // persistent CLI process. + // server entry into the spawn-time `--mcp-config` JSON + // unconditionally for Claude / Codex — the server carries the + // always-on scheduling tools (ScheduleWakeup / Cron*) alongside + // the toggleable `send_to_user` tool, so injection cannot be + // gated on the Agent Attachments plugin without taking + // scheduling offline. The toggle now gates only that tool's + // call-time policy + prompt nudge, not the whole server. The + // bridge is stored on the session below so it lives exactly as + // long as the persistent CLI process. let mut spawn_settings = agent_settings.clone(); let bridge = match spawn_settings.backend_runtime.harness { - AgentBackendRuntimeHarness::ClaudeCode => Some(if send_to_user_enabled { - let (b, mcp_with_claudette) = start_bridge_and_inject_mcp( - &app, - &state.db_path, - &workspace_id, - &chat_session_id, - agent_settings.mcp_config.clone(), - ) - .await?; - spawn_settings.mcp_config = mcp_with_claudette; - b - } else { - start_chat_bridge(&app, &state.db_path, &workspace_id, &chat_session_id).await? - }), - AgentBackendRuntimeHarness::CodexAppServer if send_to_user_enabled => { + // The Claudette MCP server carries always-on scheduling tools + // (ScheduleWakeup / Cron*) alongside the toggleable send_to_user + // tool, so it is injected unconditionally — the send_to_user + // plugin toggle gates that tool's prompt nudge (above), not the + // whole server. Codex consumes the same MCP server as Claude. + AgentBackendRuntimeHarness::ClaudeCode | AgentBackendRuntimeHarness::CodexAppServer => { let (b, mcp_with_claudette) = start_bridge_and_inject_mcp( &app, &state.db_path, @@ -2375,7 +2378,6 @@ pub async fn send_chat_message( spawn_settings.mcp_config = mcp_with_claudette; Some(b) } - AgentBackendRuntimeHarness::CodexAppServer => None, #[cfg(feature = "pi-sdk")] AgentBackendRuntimeHarness::PiSdk => None, }; diff --git a/src-tauri/src/commands/scheduling.rs b/src-tauri/src/commands/scheduling.rs index 3c19572a6..f2d7198ff 100644 --- a/src-tauri/src/commands/scheduling.rs +++ b/src-tauri/src/commands/scheduling.rs @@ -2,9 +2,10 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use serde::Serialize; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use claudette::db::Database; +use claudette::model::{ChatMessage, ChatRole}; use claudette::scheduling::{ScheduledTask, ScheduledTaskKind, cron_to_human}; use crate::state::AppState; @@ -27,12 +28,16 @@ impl From for ScheduledTaskView { } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn schedule_wakeup( session_id: String, delay_seconds: Option, fire_at: Option, prompt: String, reason: Option, + backend_id: Option, + model: Option, + app: AppHandle, state: State<'_, AppState>, ) -> Result { if prompt.trim().is_empty() { @@ -41,35 +46,69 @@ pub async fn schedule_wakeup( let fire_at = resolve_fire_at(delay_seconds, fire_at.as_deref())?; let db = Database::open(&state.db_path).map_err(|e| e.to_string())?; let task = db - .create_agent_wakeup(&session_id, fire_at, prompt.trim(), reason.as_deref()) + .create_agent_wakeup( + &session_id, + fire_at, + prompt.trim(), + reason.as_deref(), + backend_id.as_deref(), + model.as_deref(), + ) .map_err(|e| e.to_string())?; state.scheduler_notify.notify_waiters(); + post_creation_note( + &app, + &state.db_path, + &task.workspace_id, + &task.chat_session_id, + format!( + "Scheduled wakeup for {}. Manage it from the scheduler (clock icon in the sidebar).", + fire_at.to_rfc3339() + ), + ); Ok(task.into()) } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn create_cron_routine( session_id: String, name: Option, cron_expr: String, prompt: String, recurring: Option, + backend_id: Option, + model: Option, + app: AppHandle, state: State<'_, AppState>, ) -> Result { if prompt.trim().is_empty() { return Err("prompt is required".to_string()); } + let cron_expr = cron_expr.trim().to_string(); let db = Database::open(&state.db_path).map_err(|e| e.to_string())?; let task = db .create_agent_cron_task( &session_id, name.as_deref(), - cron_expr.trim(), + &cron_expr, prompt.trim(), recurring.unwrap_or(true), + backend_id.as_deref(), + model.as_deref(), ) .map_err(|e| e.to_string())?; state.scheduler_notify.notify_waiters(); + post_creation_note( + &app, + &state.db_path, + &task.workspace_id, + &task.chat_session_id, + format!( + "Looping (`{cron_expr}` — {}). Manage it from the scheduler (clock icon in the sidebar).", + cron_to_human(&cron_expr) + ), + ); Ok(task.into()) } @@ -183,13 +222,34 @@ pub async fn dispatch_task_prompt( source: &str, ) -> Result<(), String> { let prompt = build_dispatch_prompt(&task, source); - dispatch_prompt_to_session(app, task.chat_session_id, prompt).await + // Forward the row's pinned backend / model so a cron created from a + // Codex or Pi chat fires on the same runtime it was scheduled under. + // Either being `None` keeps the existing global-default fallback (the + // case for agent-callable rows and pre-migration rows). + dispatch_prompt_to_session_with_backend( + app, + task.chat_session_id, + prompt, + task.backend_id, + task.model, + ) + .await } pub async fn dispatch_prompt_to_session( app: AppHandle, chat_session_id: String, prompt: String, +) -> Result<(), String> { + dispatch_prompt_to_session_with_backend(app, chat_session_id, prompt, None, None).await +} + +async fn dispatch_prompt_to_session_with_backend( + app: AppHandle, + chat_session_id: String, + prompt: String, + backend_id: Option, + model: Option, ) -> Result<(), String> { let state = app.state::(); crate::commands::chat::send::send_chat_message( @@ -198,14 +258,14 @@ pub async fn dispatch_prompt_to_session( prompt, None, None, + model, None, None, None, None, None, None, - None, - None, + backend_id, None, app.clone(), state, @@ -213,6 +273,51 @@ pub async fn dispatch_prompt_to_session( .await } +/// Append a system-role chat message announcing that a wakeup / cron was +/// scheduled, and emit `chat-system-message` so the chat panel renders it +/// inline. Persisted to the DB so it survives reloads (and is visible +/// across all backends without any frontend per-backend code) — replaces +/// the prior frontend `addLocalMessage` which evaporated on refresh. +/// Best-effort: a failure here doesn't unwind the scheduling itself. +fn post_creation_note( + app: &AppHandle, + db_path: &std::path::Path, + workspace_id: &str, + chat_session_id: &str, + content: String, +) { + let message = ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + workspace_id: workspace_id.to_string(), + chat_session_id: chat_session_id.to_string(), + role: ChatRole::System, + content, + cost_usd: None, + duration_ms: None, + // Match the format every other Tauri-emitted chat message uses + // (Unix-seconds string from `now_iso()`). Mixing formats breaks + // the frontend's string-sort over `created_at`. + created_at: crate::commands::chat::now_iso(), + thinking: None, + input_tokens: None, + output_tokens: None, + cache_read_tokens: None, + cache_creation_tokens: None, + }; + match Database::open(db_path).and_then(|db| db.insert_chat_message(&message)) { + Ok(()) => { + let _ = app.emit("chat-system-message", &message); + } + Err(err) => { + tracing::warn!( + target: "claudette::scheduling", + error = %err, + "failed to persist schedule confirmation message" + ); + } + } +} + fn build_dispatch_prompt(task: &ScheduledTask, source: &str) -> String { let label = match task.kind { ScheduledTaskKind::Wakeup => "Scheduled wakeup fired", @@ -268,6 +373,8 @@ mod tests { updated_at: "now".into(), last_fired_at: None, next_fire_at: None, + backend_id: None, + model: None, } } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 3786b1c43..b8f35ee69 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -941,6 +941,15 @@ async fn handle_schedule_wakeup( .get("reason") .and_then(|v| v.as_str()) .map(ToOwned::to_owned); + let backend_id = params + .get("backend_id") + .or_else(|| params.get("backendId")) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); + let model = params + .get("model") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); let state = app_state(app)?; let value = crate::commands::scheduling::schedule_wakeup( session_id, @@ -948,6 +957,9 @@ async fn handle_schedule_wakeup( fire_at, prompt, reason, + backend_id, + model, + app.clone(), state, ) .await?; @@ -968,9 +980,26 @@ async fn handle_routine_create( .and_then(|v| v.as_str()) .map(ToOwned::to_owned); let recurring = params.get("recurring").and_then(|v| v.as_bool()); + let backend_id = params + .get("backend_id") + .or_else(|| params.get("backendId")) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); + let model = params + .get("model") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); let state = app_state(app)?; let value = crate::commands::scheduling::create_cron_routine( - session_id, name, cron_expr, prompt, recurring, state, + session_id, + name, + cron_expr, + prompt, + recurring, + backend_id, + model, + app.clone(), + state, ) .await?; serde_json::to_value(value).map_err(|e| e.to_string()) diff --git a/src/agent/pi_sdk.rs b/src/agent/pi_sdk.rs index b7850b717..b9aa148bc 100644 --- a/src/agent/pi_sdk.rs +++ b/src/agent/pi_sdk.rs @@ -17,8 +17,14 @@ use super::{ InnerStreamEvent, StartContentBlock, StreamEvent, TokenUsage, TokenUsageIteration, TurnHandle, UserContentBlock, UserEventMessage, UserMessageContent, }; +use crate::agent_mcp::bridge::Sink; +use crate::agent_mcp::protocol::{BridgePayload, BridgeResponse}; type PiStdin = Arc>; +/// Shared host-side tool sink. Pi has no MCP bridge, so a `host_tool` +/// sidecar message is routed straight through this `Sink` (the same +/// `ChatBridgeSink` Claude/Codex reach over their MCP socket). +type PiSink = Arc; type PendingRequests = Arc>>; type TurnOutput = Arc>; type InitCacheHandle = Arc>; @@ -86,7 +92,9 @@ struct InitCache { init: Option, } -#[derive(Debug, Clone)] +// No `Debug` derive: `sink` is a `dyn Sink` trait object, which is not +// `Debug`. `PiSdkOptions` is built once and consumed, never formatted. +#[derive(Clone)] pub struct PiSdkOptions { pub model: Option, pub thinking_level: Option, @@ -106,6 +114,10 @@ pub struct PiSdkOptions { /// in the provider auth UI. Maps a Pi-recognized env var name /// (e.g. `OPENROUTER_API_KEY`) to its value. Empty by default. pub pi_provider_env: Vec<(String, String)>, + /// Host-side tool sink for `host_tool` round-trips from the sidecar + /// (native scheduling tools). `None` disables them — the sidecar's + /// scheduling tools then report "scheduling unavailable". + pub sink: Option, } pub struct PiSdkSession { @@ -122,6 +134,9 @@ pub struct PiSdkSession { working_dir: PathBuf, turn_output: TurnOutput, init_cache: InitCacheHandle, + /// Host-side tool sink for `host_tool` sidecar round-trips. Cloned + /// into the stdout reader task at spawn time. + sink: Option, } /// Setup knobs for `PiSdkSession::spawn_initialized`. `start` and @@ -141,6 +156,10 @@ struct PiSpawnConfig<'a> { /// touching `~/.pi/agent/auth.json`. extra_env: Option<&'a [(String, String)]>, cache_command_line: bool, + /// Host-side tool sink, forwarded to the stdout reader so `host_tool` + /// messages can be served the moment the sidecar starts. `None` for + /// control-plane sessions, which never run agent tools. + sink: Option, } impl PiSdkSession { @@ -159,6 +178,7 @@ impl PiSdkSession { Some(&options.pi_provider_env) }, cache_command_line: true, + sink: options.sink.clone(), }) .await?; if let Err(err) = session.start_session(session_id, options).await { @@ -220,6 +240,7 @@ impl PiSdkSession { workspace_env: None, extra_env, cache_command_line: false, + sink: None, }) .await } @@ -312,6 +333,7 @@ impl PiSdkSession { working_dir: config.working_dir.to_path_buf(), turn_output: Arc::new(tokio::sync::Mutex::new(PiTurnOutput::default())), init_cache: Arc::new(tokio::sync::Mutex::new(InitCache::default())), + sink: config.sink, }; session.spawn_stdout_reader(stdout); @@ -350,6 +372,7 @@ impl PiSdkSession { working_dir: PathBuf::from("/tmp"), turn_output: Arc::new(tokio::sync::Mutex::new(PiTurnOutput::default())), init_cache: Arc::new(tokio::sync::Mutex::new(InitCache::default())), + sink: None, } } @@ -632,6 +655,8 @@ impl PiSdkSession { let pending = self.pending.clone(); let turn_output = self.turn_output.clone(); let init_cache = self.init_cache.clone(); + let stdin = self.stdin.clone(); + let sink = self.sink.clone(); tokio::spawn(async move { let reader = tokio::io::BufReader::new(stdout); let mut lines = reader.lines(); @@ -640,6 +665,17 @@ impl PiSdkSession { continue; } match serde_json::from_str::(&line) { + Ok(PiHarnessMessage::HostTool { + request_id, + name, + args, + }) => { + // Served off the reader loop so a slow DB write + // never stalls stdout draining. Intercepted here + // because this is the only place that holds the + // live `stdin` + `sink`. + handle_host_tool(stdin.clone(), sink.clone(), request_id, name, args); + } Ok(message) => { route_pi_message( &event_tx, @@ -774,6 +810,18 @@ enum PiHarnessMessage { kind: String, input: Value, }, + /// A native scheduling tool in the Pi sidecar wants the host to run a + /// `BridgePayload` and return the result. Intercepted in the stdout + /// reader loop (which holds `stdin` + `sink`) — never reaches + /// [`route_pi_message`]. + #[serde(rename = "host_tool")] + HostTool { + #[serde(rename = "requestId")] + request_id: String, + name: String, + #[serde(default)] + args: Value, + }, #[serde(rename = "tool_update")] ToolUpdate { #[serde(default)] @@ -1490,10 +1538,111 @@ async fn route_pi_message( error, }); } + // Intercepted in `spawn_stdout_reader`'s loop before this routes — + // listed only to keep the match exhaustive. + PiHarnessMessage::HostTool { .. } => {} PiHarnessMessage::Unknown => {} } } +/// Serve a `host_tool` sidecar request: run its `BridgePayload` through +/// the host `Sink` and write a `host_tool_result` line back to the +/// sidecar. Spawned as a detached task so the stdout reader keeps +/// draining while the (blocking-ish) DB write runs. +fn handle_host_tool( + stdin: Option, + sink: Option, + request_id: String, + name: String, + args: Value, +) { + tokio::spawn(async move { + let response = run_host_tool(sink, &name, args).await; + let Some(stdin) = stdin else { + tracing::warn!( + target: "claudette::agent", + subsystem = "pi-sdk", + tool = %name, + "host_tool result dropped — sidecar stdin unavailable" + ); + return; + }; + let line = json!({ + "type": "host_tool_result", + "requestId": request_id, + "ok": response.ok, + "message": response.message, + "data": response.data, + "error": response.error, + }); + let Ok(mut bytes) = serde_json::to_vec(&line) else { + return; + }; + bytes.push(b'\n'); + let mut guard = stdin.lock().await; + if let Err(err) = guard.write_all(&bytes).await { + tracing::warn!( + target: "claudette::agent", + subsystem = "pi-sdk", + error = %err, + "failed to write host_tool_result to sidecar" + ); + } + }); +} + +/// Map a `host_tool` request to a `BridgePayload` and run it through the +/// sink. A missing sink (control session, or a build without scheduling) +/// degrades to an error result rather than panicking. +async fn run_host_tool(sink: Option, name: &str, args: Value) -> BridgeResponse { + let payload = match bridge_payload_for(name, args) { + Ok(payload) => payload, + Err(err) => return BridgeResponse::err(err), + }; + match sink { + Some(sink) => sink.handle(payload).await, + None => BridgeResponse::err("scheduling is unavailable for this session"), + } +} + +/// Translate a native Pi scheduling tool name + arguments into the +/// shared [`BridgePayload`]. Accepts both the camelCase keys the sidecar +/// sends and the snake_case aliases the MCP server tolerates, so the two +/// tool surfaces stay interchangeable. An unknown name is an error, not +/// a panic — a future user Pi extension can add tools without this +/// dispatch table needing to recognize them. +fn bridge_payload_for(name: &str, args: Value) -> Result { + let str_arg = |keys: &[&str]| -> Option { + keys.iter() + .find_map(|k| args.get(*k).and_then(Value::as_str)) + .map(ToOwned::to_owned) + }; + match name { + "ScheduleWakeup" => Ok(BridgePayload::ScheduleWakeup { + delay_seconds: ["delaySeconds", "delay_seconds"] + .iter() + .find_map(|k| args.get(*k).and_then(Value::as_i64)), + fire_at: str_arg(&["fireAt", "fire_at"]), + prompt: str_arg(&["prompt"]).ok_or("prompt is required")?, + reason: str_arg(&["reason"]), + }), + "CronCreate" => Ok(BridgePayload::CronCreate { + name: str_arg(&["name"]), + cron_expr: str_arg(&["cron", "cron_expr"]).ok_or("cron is required")?, + prompt: str_arg(&["prompt"]).ok_or("prompt is required")?, + recurring: args + .get("recurring") + .and_then(Value::as_bool) + .unwrap_or(true), + }), + "CronList" => Ok(BridgePayload::CronList), + "CronDelete" => Ok(BridgePayload::CronDelete { + id: str_arg(&["id", "name"]).ok_or("id is required")?, + }), + other => Err(format!("unknown host tool: {other}")), + } +} + async fn fail_pending_requests(pending: &PendingRequests, reason: &str) { let mut pending = pending.lock().await; for (_, pending) in std::mem::take(&mut *pending) { @@ -1824,6 +1973,142 @@ mod tests { assert!(matches!(msg, PiHarnessMessage::ToolRequest { .. })); } + #[test] + fn parses_host_tool() { + let value = r#"{"type":"host_tool","requestId":"t1","name":"CronCreate","args":{"cron":"0 9 * * *"}}"#; + match serde_json::from_str::(value).unwrap() { + PiHarnessMessage::HostTool { + request_id, + name, + args, + } => { + assert_eq!(request_id, "t1"); + assert_eq!(name, "CronCreate"); + assert_eq!(args["cron"], "0 9 * * *"); + } + other => panic!("expected HostTool, got {other:?}"), + } + // Zero-parameter tools omit `args`; the field must still parse. + let no_args = r#"{"type":"host_tool","requestId":"t2","name":"CronList"}"#; + assert!(matches!( + serde_json::from_str::(no_args).unwrap(), + PiHarnessMessage::HostTool { .. } + )); + } + + #[test] + fn bridge_payload_for_maps_each_scheduling_tool() { + // camelCase keys (what the sidecar sends). + match bridge_payload_for( + "ScheduleWakeup", + json!({ "prompt": "ping", "delaySeconds": 60, "reason": "later" }), + ) + .unwrap() + { + BridgePayload::ScheduleWakeup { + delay_seconds, + prompt, + reason, + .. + } => { + assert_eq!(delay_seconds, Some(60)); + assert_eq!(prompt, "ping"); + assert_eq!(reason.as_deref(), Some("later")); + } + other => panic!("expected ScheduleWakeup, got {other:?}"), + } + // snake_case aliases still work — parity with the MCP server. + assert!(matches!( + bridge_payload_for( + "ScheduleWakeup", + json!({ "prompt": "p", "delay_seconds": 5 }) + ) + .unwrap(), + BridgePayload::ScheduleWakeup { + delay_seconds: Some(5), + .. + } + )); + // CronCreate defaults `recurring` to true. + match bridge_payload_for("CronCreate", json!({ "cron": "0 9 * * *", "prompt": "go" })) + .unwrap() + { + BridgePayload::CronCreate { + cron_expr, + recurring, + name, + .. + } => { + assert_eq!(cron_expr, "0 9 * * *"); + assert!(recurring); + assert_eq!(name, None); + } + other => panic!("expected CronCreate, got {other:?}"), + } + assert!(matches!( + bridge_payload_for("CronList", json!({})).unwrap(), + BridgePayload::CronList + )); + // CronDelete accepts `name` as an alias for `id`. + assert!(matches!( + bridge_payload_for("CronDelete", json!({ "name": "morning" })).unwrap(), + BridgePayload::CronDelete { .. } + )); + } + + #[test] + fn bridge_payload_for_rejects_bad_input() { + assert!(bridge_payload_for("ScheduleWakeup", json!({})).is_err()); + assert!(bridge_payload_for("CronCreate", json!({ "prompt": "x" })).is_err()); + assert!(bridge_payload_for("CronDelete", json!({})).is_err()); + // An unrecognized tool is an error, not a panic — leaves room + // for future user Pi extensions to add their own host tools. + assert!( + bridge_payload_for("SomeFutureExtensionTool", json!({})) + .unwrap_err() + .contains("unknown host tool") + ); + } + + #[tokio::test] + async fn run_host_tool_forwards_payload_to_sink() { + struct RecordingSink { + last: std::sync::Mutex>, + } + impl Sink for RecordingSink { + fn handle( + &self, + payload: BridgePayload, + ) -> std::pin::Pin + Send + '_>> + { + Box::pin(async move { + *self.last.lock().unwrap() = Some(payload); + BridgeResponse::message("ok") + }) + } + } + let recorder = Arc::new(RecordingSink { + last: std::sync::Mutex::new(None), + }); + let sink: PiSink = recorder.clone(); + let response = run_host_tool(Some(sink), "CronDelete", json!({ "id": "task-9" })).await; + assert!(response.ok); + match recorder.last.lock().unwrap().clone() { + Some(BridgePayload::CronDelete { id }) => assert_eq!(id, "task-9"), + other => panic!("expected CronDelete to reach the sink, got {other:?}"), + } + } + + #[tokio::test] + async fn run_host_tool_degrades_without_sink_or_on_bad_input() { + // No sink (control session / scheduling-disabled build). + let no_sink = run_host_tool(None, "CronList", json!({})).await; + assert!(!no_sink.ok && no_sink.error.is_some()); + // Bad args never reach the sink. + let bad = run_host_tool(None, "ScheduleWakeup", json!({})).await; + assert!(!bad.ok && bad.error.is_some()); + } + #[test] fn test_session_reports_pid() { let session = PiSdkSession::new_for_test(42); diff --git a/src/agent_mcp/mod.rs b/src/agent_mcp/mod.rs index 81ca82ae4..b264596a0 100644 --- a/src/agent_mcp/mod.rs +++ b/src/agent_mcp/mod.rs @@ -61,15 +61,14 @@ pub fn is_builtin_plugin_enabled(db: &crate::db::Database, name: &str) -> bool { } } -/// System-prompt nudge appended to every fresh persistent session so the model -/// is aware of the agent-MCP tool's purpose — the bare `tools/list` description -/// isn't enough on its own (a model can see the tool listed and still default -/// to "save to disk" when the user says "send me a screenshot"). -/// -/// Worded as a positive instruction with the supported-type allow-list and the -/// failure-mode escape hatch both spelled out, so the model doesn't reach for -/// `send_to_user` on types it can't deliver. -pub const SYSTEM_PROMPT_NUDGE: &str = "\ +/// `send_to_user` nudge appended only when the Agent Attachments plugin is +/// enabled. The bare `tools/list` description isn't enough on its own — a +/// model can see the tool listed and still default to "save to disk" when +/// the user says "send me a screenshot". Worded as a positive instruction +/// with the supported-type allow-list and the failure-mode escape hatch +/// both spelled out, so the model doesn't reach for `send_to_user` on types +/// it can't deliver. +pub const SEND_TO_USER_NUDGE: &str = "\ Inline file delivery: this Claudette workspace exposes the MCP tool \ `mcp__claudette__send_to_user` (server `claudette`, tool `send_to_user`). \ When the user asks you to send / share / show them a file, call this tool \ @@ -82,14 +81,50 @@ Supported types: images (PNG/JPEG/GIF/WebP/SVG), PDF, plain text, CSV, \ JSON, Markdown — each with its own size cap. For any other type \ (binaries, archives, oversized files), do NOT call this tool; the call \ will be rejected. Instead, tell the user the absolute path on disk so \ -they can open it manually.\n\ -\n\ -Native scheduling: this same server exposes `ScheduleWakeup`, `CronCreate`, \ +they can open it manually."; + +/// Scheduling + Monitor nudge for MCP-served harnesses (Claude / Codex). +/// Always appended, regardless of the Agent Attachments toggle — the MCP +/// server is now injected unconditionally, so scheduling discoverability +/// must not regress when the user disables `send_to_user`. Pi has its own +/// nudge ([`PI_SCHEDULING_NUDGE`]) because it ships a different tool +/// surface (no `Monitor`, no MCP `mcp__claudette__` prefixes). +pub const MCP_SCHEDULING_NUDGE: &str = "\ +Native scheduling: this server exposes `ScheduleWakeup`, `CronCreate`, \ `CronList`, `CronDelete`, and `Monitor`. Use `ScheduleWakeup` when you need \ Claudette to wake this chat later with a prompt. Use the cron tools for \ recurring routines. Use `Monitor` to subscribe to future output from a \ background Bash task instead of polling."; +/// Compose the Claude / Codex system-prompt nudge. `MCP_SCHEDULING_NUDGE` +/// is always included (scheduling is decoupled from the Agent Attachments +/// toggle); `SEND_TO_USER_NUDGE` is only appended when the user has the +/// Agent Attachments plugin enabled. Returns `None` when nothing applies +/// (currently unreachable — scheduling is always on — but kept symmetric +/// with the call site's `Option` shape). +#[must_use] +pub fn mcp_system_prompt_nudge(send_to_user_enabled: bool) -> Option { + let mut parts: Vec<&str> = Vec::with_capacity(2); + parts.push(MCP_SCHEDULING_NUDGE); + if send_to_user_enabled { + parts.push(SEND_TO_USER_NUDGE); + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } +} + +/// Scheduling nudge for Pi SDK sessions. Pi has no Claudette MCP bridge — +/// its scheduling tools are registered as *native* sidecar tools, so this +/// is worded without the `mcp__claudette__` prefixes [`MCP_SCHEDULING_NUDGE`] +/// uses, and omits `send_to_user` / `Monitor` (Pi ships neither). +pub const PI_SCHEDULING_NUDGE: &str = "\ +Native scheduling: you have the tools `ScheduleWakeup`, `CronCreate`, \ +`CronList`, and `CronDelete`. Use `ScheduleWakeup` to wake this chat later \ +with a prompt; use the cron tools for recurring routines."; + /// Claude-CLI-only rules that reference MCP tools shipped by the Claude /// Code runtime (`AskUserQuestion`, `ExitPlanMode`). These tools do not /// exist in the Pi SDK or the Codex app-server harnesses, so the rules @@ -129,6 +164,32 @@ mod builtin_tests { assert!(is_builtin_plugin_enabled(&db, "send_to_user")); } + #[test] + fn nudge_with_send_to_user_enabled_contains_both_blocks() { + // Toggle on → scheduling + send_to_user, joined with a blank + // line. Splitting the two strings (rather than emitting one + // bundled `SYSTEM_PROMPT_NUDGE`) is what keeps scheduling + // discoverable when a user disables Agent Attachments. + let composed = mcp_system_prompt_nudge(true).expect("nudge always present"); + assert!(composed.contains("ScheduleWakeup")); + assert!(composed.contains("Monitor")); + assert!(composed.contains("send_to_user")); + assert!(composed.contains("\n\n")); + } + + #[test] + fn nudge_with_send_to_user_disabled_keeps_scheduling_block() { + // Toggle off → scheduling/Monitor guidance is still injected + // (the MCP server is unconditional), but the file-delivery + // paragraph is stripped. Pins the decoupling fix Copilot + // flagged on #980. + let composed = mcp_system_prompt_nudge(false).expect("scheduling is always on"); + assert!(composed.contains("ScheduleWakeup")); + assert!(composed.contains("Monitor")); + assert!(!composed.contains("send_to_user")); + assert!(!composed.contains("Inline file delivery")); + } + #[test] fn key_format_is_stable() { assert_eq!( diff --git a/src/db/scheduling.rs b/src/db/scheduling.rs index 55146c68b..5ce36c8a0 100644 --- a/src/db/scheduling.rs +++ b/src/db/scheduling.rs @@ -12,6 +12,8 @@ impl Database { fire_at: DateTime, prompt: &str, reason: Option<&str>, + backend_id: Option<&str>, + model: Option<&str>, ) -> Result { let workspace_id = self.workspace_id_for_chat_session(chat_session_id)?; let id = uuid::Uuid::new_v4().to_string(); @@ -20,8 +22,9 @@ impl Database { self.conn.execute( "INSERT INTO agent_scheduled_tasks (id, chat_session_id, workspace_id, kind, prompt, reason, fire_at, - recurring, enabled, created_at, updated_at, next_fire_at) - VALUES (?1, ?2, ?3, 'wakeup', ?4, ?5, ?6, 0, 1, ?7, ?7, ?6)", + recurring, enabled, created_at, updated_at, next_fire_at, + backend_id, model) + VALUES (?1, ?2, ?3, 'wakeup', ?4, ?5, ?6, 0, 1, ?7, ?7, ?6, ?8, ?9)", params![ id, chat_session_id, @@ -29,7 +32,9 @@ impl Database { prompt, reason, fire_at, - now + now, + backend_id.map(str::trim).filter(|s| !s.is_empty()), + model.map(str::trim).filter(|s| !s.is_empty()), ], )?; self.get_agent_scheduled_task(&id)? @@ -43,6 +48,8 @@ impl Database { cron_expr: &str, prompt: &str, recurring: bool, + backend_id: Option<&str>, + model: Option<&str>, ) -> Result { let workspace_id = self.workspace_id_for_chat_session(chat_session_id)?; let id = uuid::Uuid::new_v4().to_string(); @@ -57,8 +64,9 @@ impl Database { self.conn.execute( "INSERT INTO agent_scheduled_tasks (id, chat_session_id, workspace_id, kind, name, prompt, cron_expr, - recurring, enabled, created_at, updated_at, next_fire_at) - VALUES (?1, ?2, ?3, 'cron', ?4, ?5, ?6, ?7, 1, ?8, ?8, ?9)", + recurring, enabled, created_at, updated_at, next_fire_at, + backend_id, model) + VALUES (?1, ?2, ?3, 'cron', ?4, ?5, ?6, ?7, 1, ?8, ?8, ?9, ?10, ?11)", params![ id, chat_session_id, @@ -68,7 +76,9 @@ impl Database { cron_expr, if recurring { 1 } else { 0 }, now, - next + next, + backend_id.map(str::trim).filter(|s| !s.is_empty()), + model.map(str::trim).filter(|s| !s.is_empty()), ], )?; self.get_agent_scheduled_task(&id)? @@ -83,7 +93,7 @@ impl Database { .query_row( "SELECT id, chat_session_id, workspace_id, kind, name, prompt, reason, fire_at, cron_expr, recurring, enabled, created_at, updated_at, - last_fired_at, next_fire_at + last_fired_at, next_fire_at, backend_id, model FROM agent_scheduled_tasks WHERE id = ?1", params![id], @@ -96,7 +106,7 @@ impl Database { let mut stmt = self.conn.prepare( "SELECT id, chat_session_id, workspace_id, kind, name, prompt, reason, fire_at, cron_expr, recurring, enabled, created_at, updated_at, - last_fired_at, next_fire_at + last_fired_at, next_fire_at, backend_id, model FROM agent_scheduled_tasks ORDER BY enabled DESC, next_fire_at IS NULL, next_fire_at, created_at", )?; @@ -110,7 +120,7 @@ impl Database { let mut stmt = self.conn.prepare( "SELECT id, chat_session_id, workspace_id, kind, name, prompt, reason, fire_at, cron_expr, recurring, enabled, created_at, updated_at, - last_fired_at, next_fire_at + last_fired_at, next_fire_at, backend_id, model FROM agent_scheduled_tasks WHERE chat_session_id = ?1 ORDER BY enabled DESC, next_fire_at IS NULL, next_fire_at, created_at", @@ -127,7 +137,7 @@ impl Database { let mut stmt = self.conn.prepare( "SELECT id, chat_session_id, workspace_id, kind, name, prompt, reason, fire_at, cron_expr, recurring, enabled, created_at, updated_at, - last_fired_at, next_fire_at + last_fired_at, next_fire_at, backend_id, model FROM agent_scheduled_tasks WHERE enabled = 1 AND next_fire_at IS NOT NULL AND next_fire_at <= ?1 ORDER BY next_fire_at, created_at", @@ -249,6 +259,8 @@ fn parse_scheduled_task_row(row: &rusqlite::Row<'_>) -> rusqlite::Result, pub next_fire_at: Option, + /// Backend the task was scheduled under. The scheduler passes this + /// through to `send_chat_message` so a Codex- or Pi-chat cron fires + /// on its own backend instead of falling through to the global + /// `default_agent_backend` app setting. `None` for legacy rows and + /// for agent-callable scheduling that chose not to pin a backend — + /// those keep the prior global-default behavior. + pub backend_id: Option, + /// Model id captured at schedule time. Forwarded to + /// `send_chat_message` like [`Self::backend_id`]. + pub model: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/ui/src/components/chat/nativeSlashCommands.ts b/src/ui/src/components/chat/nativeSlashCommands.ts index aba97650a..370b6153d 100644 --- a/src/ui/src/components/chat/nativeSlashCommands.ts +++ b/src/ui/src/components/chat/nativeSlashCommands.ts @@ -798,15 +798,19 @@ const loopHandler: NativeHandler = { return handled; } try { + // Pin the toolbar's current backend + model on the row so the cron + // fires on the runtime the user picked, not on whatever happens to + // be the global default at fire time. The host-side command now + // also writes a System chat message announcing the schedule (one + // persisted note for all 3 backends — no per-backend wiring here). await createCronRoutine({ sessionId: ctx.sessionId, cronExpr, prompt, recurring: true, + backendId: ctx.selectedModelProvider || undefined, + model: ctx.selectedModel || undefined, }); - ctx.addLocalMessage( - `Looping (\`${cronExpr}\`). Manage it from the scheduler (clock icon in the sidebar).`, - ); } catch (e) { ctx.addLocalMessage(`/loop failed: ${String(e)}`); } @@ -836,16 +840,18 @@ const scheduleHandler: NativeHandler = { const rest = trimmed.slice(firstTok.length).trim(); if (!Number.isNaN(parsed.getTime()) && ctx.sessionId && rest) { // Inline schedule: known time, known prompt, known session — fire - // the wakeup directly instead of opening the modal. + // the wakeup directly instead of opening the modal. Pin the + // toolbar's backend + model so the cron fires on the runtime the + // user picked. Confirmation message is persisted by the host + // command (one path for all 3 backends). try { await scheduleWakeup({ sessionId: ctx.sessionId, fireAt: parsed.toISOString(), prompt: rest, + backendId: ctx.selectedModelProvider || undefined, + model: ctx.selectedModel || undefined, }); - ctx.addLocalMessage( - `Scheduled for ${parsed.toLocaleString()}. Manage it from the scheduler (clock icon in the sidebar).`, - ); } catch (e) { ctx.addLocalMessage(`/schedule failed: ${String(e)}`); } diff --git a/src/ui/src/services/tauri/scheduling.ts b/src/ui/src/services/tauri/scheduling.ts index cbcdd996b..c83191f1b 100644 --- a/src/ui/src/services/tauri/scheduling.ts +++ b/src/ui/src/services/tauri/scheduling.ts @@ -18,6 +18,14 @@ export interface ScheduledTask { updated_at: string; last_fired_at: string | null; next_fire_at: string | null; + /** Backend the task was scheduled under. The scheduler forwards this to + * `send_chat_message` on fire so a cron created from a Codex / Pi chat + * runs on the same runtime it was scheduled under. `null` falls back + * to the global default backend at fire time. */ + backend_id: string | null; + /** Model id captured at schedule time. `null` falls back to the + * backend's default model at fire time. */ + model: string | null; human_schedule: string | null; } @@ -34,13 +42,16 @@ export function runScheduledRoutine(id: string): Promise<{ ok: boolean }> { } /** Schedule a one-shot wakeup. Either `delaySeconds` or `fireAt` (RFC3339) - * must be provided. */ + * must be provided. Pass `backendId` / `model` to pin the runtime the + * fired turn will use; both default to the global default backend. */ export function scheduleWakeup(args: { sessionId: string; delaySeconds?: number; fireAt?: string; prompt: string; reason?: string; + backendId?: string; + model?: string; }): Promise { return invoke("schedule_wakeup", { sessionId: args.sessionId, @@ -48,17 +59,22 @@ export function scheduleWakeup(args: { fireAt: args.fireAt ?? null, prompt: args.prompt, reason: args.reason ?? null, + backendId: args.backendId ?? null, + model: args.model ?? null, }); } /** Create a recurring cron routine. `cronExpr` is the standard 5-field - * cron expression interpreted in local time. */ + * cron expression interpreted in local time. See [`scheduleWakeup`] for + * the `backendId` / `model` semantics. */ export function createCronRoutine(args: { sessionId: string; name?: string; cronExpr: string; prompt: string; recurring?: boolean; + backendId?: string; + model?: string; }): Promise { return invoke("create_cron_routine", { sessionId: args.sessionId, @@ -66,5 +82,7 @@ export function createCronRoutine(args: { cronExpr: args.cronExpr, prompt: args.prompt, recurring: args.recurring ?? true, + backendId: args.backendId ?? null, + model: args.model ?? null, }); }