diff --git a/site/src/content/docs/features/agent-scheduling.mdx b/site/src/content/docs/features/agent-scheduling.mdx index d9cbee234..37eafaced 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: +Agents can schedule their own wakeups and routines through 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. | +| 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. @@ -34,3 +36,44 @@ claudette routine delete weekday-prs ``` `routine create` accepts literal prompts, `@file` prompts, and `-` for stdin, matching `chat send`. + +## Loops and Schedules view + +You can drive the same scheduling backend from the GUI without the agent involved. + +Click the **clock icon** next to the dashboard icon in the sidebar header to open the **Loops and Schedules** view. Like the Dashboard, it keeps the sidebar and header (panel toggles) in place — click the clock again, the dashboard icon, or a workspace to leave it. + +The page lists every persisted task, split into two sections: + +- **Loops** — cron routines (`kind: cron`). Shows the human-readable schedule, recurring/one-shot mode, target workspace, and next-fire countdown. +- **Schedules** — one-shot wakeups (`kind: wakeup`). Shows the fire time, countdown, and target workspace. + +Each row shows its target workspace, and is annotated **new session each run** when the task creates a fresh session rather than reusing one. Each row has **Run now** (fires immediately, same as the CLI's `routine run`) and **Delete** buttons. The same list also appears under **Settings → Automation**. + +### Creating a task from the GUI + +The **New scheduled task** button at the top of the page opens a dialog with two modes: + +- **One-shot (wakeup)** — pick a target, a date/time, and a prompt. +- **Recurring (cron)** — pick a target, write a standard 5-field cron expression (e.g. `0 9 * * 1-5`), optionally name the routine, choose recurring or one-shot, and write the prompt. + +**Choosing a target.** The dialog has a cascading picker: select a **repository**, then a **workspace** in it, then either an existing **session** in that workspace *or* **✦ New session each run**. New-session tasks create a brand-new chat session in the workspace every time they fire — so a recurring loop gets a clean session per run instead of piling onto one. (The cascade can target any workspace, not just the one you currently have open.) + +### Seeing what fired + +When a task fires, the triggering prompt renders in the target session as a normal user message, tagged with a **Scheduled** badge so it's clear the agent's work was kicked off by the scheduler and not typed by you. The badge persists across reloads and survives session forking. + +### Slash commands + +In a chat, you can create tasks for the current session without opening the dialog: + +``` +/loop 5m run the test suite +/schedule 2026-06-01T09:00 cut the release branch +/schedule # opens the dialog (no args) +/schedule write the weekly status update # opens the dialog, prompt prefilled +``` + +`/loop ` translates an interval into a cron expression and creates a recurring routine in the current session. The interval must evenly divide its unit: minutes that divide an hour (e.g. `1m`, `5m`, `15m`, `30m`), hours that divide a day (e.g. `1h`, `2h`, `6h`, `12h`), or `1d`. Any other interval (sub-minute, or one that doesn't divide evenly) opens the dialog in cron mode so you can write the expression directly. + +`/schedule ` accepts a leading timestamp — `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, or a date and time as two tokens (`YYYY-MM-DD HH:MM`) — interpreted in your local time. If both the time and prompt are present, it creates the wakeup inline; otherwise it opens the dialog prefilled with whatever you provided. diff --git a/site/src/content/docs/features/slash-commands.mdx b/site/src/content/docs/features/slash-commands.mdx index 0a4b5b6e2..d238ce7a5 100644 --- a/site/src/content/docs/features/slash-commands.mdx +++ b/site/src/content/docs/features/slash-commands.mdx @@ -21,6 +21,8 @@ Native commands ship with the app and always work. They split into two flavors: | `/status` | Show session/permission status. | | `/help` | Show available commands. | | `/init` | Generate a `CLAUDE.md` for the current repo. | +| `/loop` ` ` | Schedule a recurring cron routine on this chat session (e.g. `/loop 5m run the tests`). See [Agent Scheduling → Loops and Schedules view](/claudette/features/agent-scheduling/#loops-and-schedules-view). | +| `/schedule` `[] []` | Schedule a one-shot wakeup on this chat session, or open the New scheduled task dialog if args are missing. | | `/login` | Start sign-in for the active model provider. Claude models open the Claude Code browser/manual-code flow; Codex models run `codex login`; Pi models show `pi auth` guidance (run `pi auth` in a terminal, then refresh Pi models — Claudette does not query Pi auth status). | | `/config` `[section]` | Open Claudette settings (optional section: `general`, `models`, `usage`, `appearance`, `notifications`, `git`, `plugins`, `experimental`). | | `/usage` | Open the Claude Code usage panel. | 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-server/src/handler.rs b/src-server/src/handler.rs index e67ee9fc8..584dd2091 100644 --- a/src-server/src/handler.rs +++ b/src-server/src/handler.rs @@ -575,6 +575,7 @@ async fn handle_send_chat_message( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; db.insert_chat_message(&user_msg) .map_err(|e| e.to_string())?; @@ -1131,6 +1132,7 @@ async fn handle_stop_agent( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; db.insert_chat_message(&msg).map_err(|e| e.to_string())?; Ok(json!(null)) @@ -1387,6 +1389,7 @@ async fn dispatch_queued_message( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; db.insert_chat_message(&user_msg) .map_err(|e| e.to_string())?; diff --git a/src-server/tests/chat_snapshot.rs b/src-server/tests/chat_snapshot.rs index be30949b2..cf85a0721 100644 --- a/src-server/tests/chat_snapshot.rs +++ b/src-server/tests/chat_snapshot.rs @@ -93,6 +93,7 @@ fn make_message(id: &str, workspace_id: &str, session_id: &str, role: ChatRole) output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } diff --git a/src-tauri/src/agent_mcp_sink.rs b/src-tauri/src/agent_mcp_sink.rs index 75e2c2559..171c1bd08 100644 --- a/src-tauri/src/agent_mcp_sink.rs +++ b/src-tauri/src/agent_mcp_sink.rs @@ -23,6 +23,7 @@ use claudette::agent_mcp::protocol::{BridgePayload, BridgeResponse}; use claudette::agent_mcp::tools::send_to_user::policy; use claudette::db::Database; use claudette::model::{Attachment, AttachmentOrigin}; +use claudette::scheduling::ScheduleTarget; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; @@ -107,6 +108,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 +214,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( + &ScheduleTarget::Session(chat_session_id), + fire_at, + &prompt, + reason.as_deref(), + None, + None, + ) { Ok(task) => { app.state::().scheduler_notify.notify_waiters(); BridgeResponse::data( @@ -215,11 +252,13 @@ fn create_cron( Err(err) => return BridgeResponse::err(format!("open db: {err}")), }; match db.create_agent_cron_task( - &chat_session_id, + &ScheduleTarget::Session(chat_session_id), name.as_deref(), &cron_expr, &prompt, recurring, + None, + None, ) { Ok(task) => { app.state::().scheduler_notify.notify_waiters(); diff --git a/src-tauri/src/commands/chat/lifecycle.rs b/src-tauri/src/commands/chat/lifecycle.rs index bd60f63fd..aa2d04102 100644 --- a/src-tauri/src/commands/chat/lifecycle.rs +++ b/src-tauri/src/commands/chat/lifecycle.rs @@ -122,6 +122,7 @@ pub async fn stop_agent( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; db.insert_chat_message(&msg).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/chat/remote_control.rs b/src-tauri/src/commands/chat/remote_control.rs index dc95c6930..8307aa269 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")] @@ -484,13 +481,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"); @@ -573,7 +575,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, @@ -584,8 +592,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)?); @@ -945,6 +951,7 @@ async fn ensure_remote_control_monitor( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; if let Ok(db) = Database::open(&db_path) { let _ = db.insert_chat_message(&msg); @@ -1029,6 +1036,7 @@ async fn ensure_remote_control_monitor( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; let _ = db.insert_chat_message(&msg); } @@ -1379,6 +1387,7 @@ mod tests { output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } diff --git a/src-tauri/src/commands/chat/send.rs b/src-tauri/src/commands/chat/send.rs index 1646f4b85..26c604835 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; @@ -332,6 +332,7 @@ fn post_agent_auth_failure_message( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; match Database::open(db_path).and_then(|db| db.insert_chat_message(&message)) { @@ -781,6 +782,7 @@ fn prepare_user_send( message_id: Option, content: &str, attachments: Option<&[AttachmentInput]>, + scheduled_task_id: Option, ) -> Result { let user_msg = ChatMessage { id: message_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), @@ -796,6 +798,7 @@ fn prepare_user_send( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id, }; let mut att_models: Vec = Vec::new(); @@ -1022,6 +1025,8 @@ pub async fn steer_queued_chat_message( message_id, &content, attachments.as_deref(), + // Steering re-sends a user-edited prompt; never a scheduled fire. + None, )?; // Steer goes straight to the live persistent session, so the harness @@ -1088,7 +1093,7 @@ pub async fn steer_queued_chat_message( #[allow(clippy::too_many_arguments)] #[tracing::instrument( target = "claudette::chat", - skip(content, mentioned_files, attachments, app, state), + skip(content, mentioned_files, attachments, scheduled_task_id, app, state), fields( chat_session_id = %session_id, message_id = message_id.as_deref(), @@ -1114,6 +1119,10 @@ pub async fn send_chat_message( disable_1m_context: Option, backend_id: Option, attachments: Option>, + // Set by the scheduler when a scheduled task fires this prompt, so the + // persisted user message carries the task id for the "Scheduled" badge. + // `None` for ordinary user / IPC sends. + scheduled_task_id: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -1166,6 +1175,7 @@ pub async fn send_chat_message( message_id, &content, attachments.as_deref(), + scheduled_task_id, )?; // Resolve the backend runtime *before* persisting the user turn so // a harness/payload incompatibility (e.g. Pi + attachments) bails @@ -1468,26 +1478,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; @@ -1931,6 +1947,7 @@ pub async fn send_chat_message( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; if let Err(err) = db.insert_chat_message(&warning) { // Logging-only: a missing warning shouldn't block the turn. @@ -1972,6 +1989,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, @@ -1985,6 +2013,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 @@ -2117,6 +2147,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?; @@ -2168,22 +2199,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, @@ -2195,7 +2214,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, }; @@ -2350,27 +2368,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, @@ -2382,7 +2396,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, }; @@ -2883,6 +2896,7 @@ pub async fn send_chat_message( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; let _ = db.insert_chat_message(&msg); } @@ -3445,6 +3459,7 @@ mod tests { output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } diff --git a/src-tauri/src/commands/scheduling.rs b/src-tauri/src/commands/scheduling.rs index 906092885..afa0df53a 100644 --- a/src-tauri/src/commands/scheduling.rs +++ b/src-tauri/src/commands/scheduling.rs @@ -2,13 +2,45 @@ 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::scheduling::{ScheduledTask, ScheduledTaskKind, cron_to_human}; +use claudette::model::{ChatMessage, ChatRole}; +use claudette::scheduling::{ScheduleTarget, ScheduledTask, ScheduledTaskKind, cron_to_human}; use crate::state::AppState; +/// Resolve the GUI/IPC command arguments into a [`ScheduleTarget`]. +/// +/// `create_new_session` requires a `workspace_id` (the scheduler makes a fresh +/// session there each fire); otherwise a concrete `session_id` is reused. +fn build_schedule_target( + session_id: Option, + workspace_id: Option, + create_new_session: Option, +) -> Result { + if create_new_session.unwrap_or(false) { + let ws = workspace_id + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or("workspace_id is required when create_new_session is set")?; + Ok(ScheduleTarget::NewSessionInWorkspace(ws)) + } else { + let sid = session_id + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + match sid { + Some(sid) => Ok(ScheduleTarget::Session(sid)), + // A caller that supplied only a workspace likely meant new-session. + None if workspace_id.is_some() => Err( + "session_id is required, or set create_new_session: true to target a workspace" + .to_string(), + ), + None => Err("session_id is required".to_string()), + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct ScheduledTaskView { #[serde(flatten)] @@ -27,49 +59,93 @@ impl From for ScheduledTaskView { } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn schedule_wakeup( - session_id: String, + session_id: Option, + workspace_id: Option, + create_new_session: Option, 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() { return Err("prompt is required".to_string()); } + let target = build_schedule_target(session_id, workspace_id, create_new_session)?; 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( + &target, + 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.as_deref(), + 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, + session_id: Option, + workspace_id: Option, + create_new_session: Option, 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 target = build_schedule_target(session_id, workspace_id, create_new_session)?; + 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, + &target, 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.as_deref(), + format!( + "Looping (`{cron_expr}` — {}). Manage it from the scheduler (clock icon in the sidebar).", + cron_to_human(&cron_expr) + ), + ); Ok(task.into()) } @@ -233,34 +309,198 @@ 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 + // Resolve where this fire lands. `create_new_session` rows make a fresh + // session in the target workspace on every fire (so a recurring cron gets + // a clean session per run); reuse rows dispatch into their stored session. + if task.create_new_session { + let state = app.state::(); + let db = Database::open(&state.db_path).map_err(|e| e.to_string())?; + let session = db + .create_chat_session(&task.workspace_id) + .map_err(|e| e.to_string())?; + let session_id = session.id.clone(); + let result = dispatch_prompt_to_session_inner( + app.clone(), + session_id.clone(), + prompt, + task.backend_id, + task.model, + Some(task.id), + // Announce the fresh session to the frontend so its tab appears + // before the live `chat-message` lands. + Some(session), + ) + .await; + if result.is_err() + && let Ok(db) = Database::open(&app.state::().db_path) + { + // The fire never started — roll back the empty session we made so + // a persistently-failing cron doesn't accrete blank tabs. + let _ = db.archive_chat_session(&session_id); + } + result + } else { + let session_id = task + .chat_session_id + .clone() + .ok_or("scheduled task has no target session")?; + // 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. + dispatch_prompt_to_session_inner( + app, + session_id, + prompt, + task.backend_id, + task.model, + Some(task.id), + None, + ) + .await + } } +/// Dispatch a backend-originated prompt into a session (used by the agent +/// `Monitor` tool). Renders the injected prompt like any other backend-origin +/// send — see [`dispatch_prompt_to_session_inner`]. pub async fn dispatch_prompt_to_session( app: AppHandle, chat_session_id: String, prompt: String, +) -> Result<(), String> { + dispatch_prompt_to_session_inner(app, chat_session_id, prompt, None, None, None, None).await +} + +/// Run the turn via `send_chat_message` (which persists the user row by the +/// `message_id` we pass, carrying `scheduled_task_id` for the "Scheduled" +/// badge), then — only on success — emit the user message as a `chat-message` +/// so a focused session renders it live, the same way Claude Remote Control +/// surfaces remote-origin prompts that skip the GUI's optimistic insert. +/// +/// Emitting *after* `send_chat_message` returns `Ok` (it persists + spawns the +/// stream task, then returns) avoids leaving a phantom badged prompt on screen +/// when send fails before persisting (e.g. an unresolvable pinned backend). +/// When `announce_session` is set (a freshly created new-session fire), its +/// `chat-session-created` event is emitted first so the tab exists before the +/// message arrives. +#[allow(clippy::too_many_arguments)] +async fn dispatch_prompt_to_session_inner( + app: AppHandle, + chat_session_id: String, + prompt: String, + backend_id: Option, + model: Option, + scheduled_task_id: Option, + announce_session: Option, ) -> Result<(), String> { let state = app.state::(); - crate::commands::chat::send::send_chat_message( - chat_session_id, - None, - prompt, - None, - None, + let message_id = uuid::Uuid::new_v4().to_string(); + + let result = crate::commands::chat::send::send_chat_message( + chat_session_id.clone(), + Some(message_id.clone()), + prompt.clone(), None, None, + model, None, None, None, None, None, None, + backend_id, None, + scheduled_task_id.clone(), app.clone(), state, ) - .await + .await; + + if result.is_ok() { + if let Some(session) = announce_session { + let _ = app.emit("chat-session-created", &session); + } + // The row is already persisted by `send_chat_message` (via + // `prepare_user_send`) under `message_id`; this emit just mirrors it + // into the live store. Best-effort: a focused session renders it, and + // a reload reads the same persisted row. + if let Ok(db) = Database::open(&app.state::().db_path) + && let Ok(Some(session)) = db.get_chat_session(&chat_session_id) + { + let user_msg = ChatMessage { + id: message_id, + workspace_id: session.workspace_id, + chat_session_id, + role: ChatRole::User, + content: prompt, + cost_usd: None, + duration_ms: None, + created_at: crate::commands::chat::now_iso(), + thinking: None, + input_tokens: None, + output_tokens: None, + cache_read_tokens: None, + cache_creation_tokens: None, + scheduled_task_id, + }; + let _ = app.emit("chat-message", &user_msg); + } + } + + result +} + +/// 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: Option<&str>, + content: String, +) { + // New-session tasks have no session at schedule time — there's nowhere to + // post the confirmation, so skip it. The task still appears in the + // scheduler view. + let Some(chat_session_id) = chat_session_id else { + return; + }; + 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, + scheduled_task_id: 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 { @@ -312,8 +552,9 @@ mod tests { fn task(kind: ScheduledTaskKind) -> ScheduledTask { ScheduledTask { id: "task-1".into(), - chat_session_id: "chat-1".into(), + chat_session_id: Some("chat-1".into()), workspace_id: "ws-1".into(), + create_new_session: false, kind, name: Some("morning".into()), prompt: "Check the PR".into(), @@ -330,6 +571,8 @@ mod tests { last_failed_at: None, last_error: None, disabled_reason: None, + backend_id: None, + model: None, } } diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index b2b1ef8d3..051e91345 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -814,6 +814,7 @@ pub(crate) async fn archive_workspace_inner( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }; if let Err(err) = db.insert_chat_message(&msg) { tracing::warn!( @@ -2354,6 +2355,7 @@ mod tests { output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index e9bc01a72..2a79f9a0b 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -925,6 +925,8 @@ async fn handle_send_chat_message( parsed.disable_1m_context, parsed.backend_id, parsed.attachments, + // IPC / CLI sends are user-driven, never scheduler-fired. + None, app.clone(), state, ) @@ -938,7 +940,17 @@ async fn handle_schedule_wakeup( ) -> Result { let session_id = string_param(params, "session_id") .or_else(|_| string_param(params, "chat_session_id")) - .or_else(|_| string_param(params, "session"))?; + .or_else(|_| string_param(params, "session")) + .ok(); + let workspace_id = params + .get("workspace_id") + .or_else(|| params.get("workspaceId")) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); + let create_new_session = params + .get("create_new_session") + .or_else(|| params.get("createNewSession")) + .and_then(|v| v.as_bool()); let prompt = string_param(params, "prompt")?; let delay_seconds = params .get("delay_seconds") @@ -953,13 +965,27 @@ 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, + workspace_id, + create_new_session, delay_seconds, fire_at, prompt, reason, + backend_id, + model, + app.clone(), state, ) .await?; @@ -972,7 +998,17 @@ async fn handle_routine_create( ) -> Result { let session_id = string_param(params, "session_id") .or_else(|_| string_param(params, "chat_session_id")) - .or_else(|_| string_param(params, "session"))?; + .or_else(|_| string_param(params, "session")) + .ok(); + let workspace_id = params + .get("workspace_id") + .or_else(|| params.get("workspaceId")) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned); + let create_new_session = params + .get("create_new_session") + .or_else(|| params.get("createNewSession")) + .and_then(|v| v.as_bool()); let cron_expr = string_param(params, "cron_expr").or_else(|_| string_param(params, "cron"))?; let prompt = string_param(params, "prompt")?; let name = params @@ -980,9 +1016,28 @@ 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, + workspace_id, + create_new_session, + name, + cron_expr, + prompt, + recurring, + backend_id, + model, + app.clone(), + state, ) .await?; serde_json::to_value(value).map_err(|e| e.to_string()) @@ -1503,6 +1558,7 @@ mod tests { output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } diff --git a/src/agent/history_seeder.rs b/src/agent/history_seeder.rs index 1fed90701..8de053eaa 100644 --- a/src/agent/history_seeder.rs +++ b/src/agent/history_seeder.rs @@ -182,6 +182,7 @@ mod tests { output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } 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/chat.rs b/src/chat.rs index bb24f9022..92fee7670 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -256,6 +256,7 @@ pub fn build_assistant_chat_message(args: BuildAssistantArgs<'_>) -> ChatMessage cache_creation_tokens: usage .as_ref() .and_then(|u| u.cache_creation_input_tokens.map(|n| n as i64)), + scheduled_task_id: None, } } @@ -289,6 +290,7 @@ pub fn build_compaction_sentinel( output_tokens: None, cache_read_tokens: Some(meta.post_tokens as i64), cache_creation_tokens: None, + scheduled_task_id: None, } } diff --git a/src/db/chat.rs b/src/db/chat.rs index d95a22382..20781872c 100644 --- a/src/db/chat.rs +++ b/src/db/chat.rs @@ -39,8 +39,9 @@ impl Database { self.conn.execute( "INSERT INTO chat_messages ( id, workspace_id, chat_session_id, role, content, cost_usd, duration_ms, thinking, - input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + scheduled_task_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ msg.id, msg.workspace_id, @@ -54,6 +55,7 @@ impl Database { msg.output_tokens, msg.cache_read_tokens, msg.cache_creation_tokens, + msg.scheduled_task_id, ], )?; Ok(()) @@ -79,12 +81,13 @@ impl Database { output_tokens: row.get(10)?, cache_read_tokens: row.get(11)?, cache_creation_tokens: row.get(12)?, + scheduled_task_id: row.get(13)?, }) } pub(super) const CHAT_MESSAGE_COLS: &str = "id, workspace_id, chat_session_id, role, content, cost_usd, \ duration_ms, created_at, thinking, input_tokens, output_tokens, cache_read_tokens, \ - cache_creation_tokens"; + cache_creation_tokens, scheduled_task_id"; /// Predicate that filters out legacy empty assistant rows (assistant role, /// empty content, no thinking text). The frontend used to drop these in diff --git a/src/db/scheduling.rs b/src/db/scheduling.rs index 5fe9d78ca..5519fcada 100644 --- a/src/db/scheduling.rs +++ b/src/db/scheduling.rs @@ -1,50 +1,94 @@ use chrono::{DateTime, Utc}; use rusqlite::{OptionalExtension, params}; -use crate::scheduling::{ScheduledTask, ScheduledTaskKind, next_cron_run_utc, utc_now_rfc3339}; +use crate::scheduling::{ + ScheduleTarget, ScheduledTask, ScheduledTaskKind, next_cron_run_utc, utc_now_rfc3339, +}; use super::Database; impl Database { + /// Resolve a [`ScheduleTarget`] into the stored `(chat_session_id, + /// workspace_id, create_new_session)` triple. Reuse-mode derives the + /// workspace from the session; new-session-mode stores the workspace + /// directly and leaves the session NULL until fire time. + fn resolve_schedule_target( + &self, + target: &ScheduleTarget, + ) -> Result<(Option, String, bool), rusqlite::Error> { + match target { + ScheduleTarget::Session(session_id) => { + let workspace_id = self.workspace_id_for_chat_session(session_id)?; + Ok((Some(session_id.clone()), workspace_id, false)) + } + ScheduleTarget::NewSessionInWorkspace(workspace_id) => { + // Mirror the implicit existence check the `Session` arm gets + // from `workspace_id_for_chat_session`: refuse to persist a row + // whose workspace doesn't exist (it could never fire, since the + // due-query joins `workspaces`). + let exists: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = ?1)", + params![workspace_id], + |row| row.get(0), + )?; + if !exists { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + Ok((None, workspace_id.clone(), true)) + } + } + } + pub fn create_agent_wakeup( &self, - chat_session_id: &str, + target: &ScheduleTarget, 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 (chat_session_id, workspace_id, create_new_session) = + self.resolve_schedule_target(target)?; let id = uuid::Uuid::new_v4().to_string(); let now = utc_now_rfc3339(); let fire_at = fire_at.to_rfc3339(); 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)", + (id, chat_session_id, workspace_id, create_new_session, kind, prompt, reason, + fire_at, recurring, enabled, created_at, updated_at, next_fire_at, + backend_id, model) + VALUES (?1, ?2, ?3, ?4, 'wakeup', ?5, ?6, ?7, 0, 1, ?8, ?8, ?7, ?9, ?10)", params![ id, chat_session_id, workspace_id, + create_new_session as i64, 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)? .ok_or(rusqlite::Error::QueryReturnedNoRows) } + #[allow(clippy::too_many_arguments)] pub fn create_agent_cron_task( &self, - chat_session_id: &str, + target: &ScheduleTarget, name: Option<&str>, 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 (chat_session_id, workspace_id, create_new_session) = + self.resolve_schedule_target(target)?; let id = uuid::Uuid::new_v4().to_string(); let now_dt = Utc::now(); let next = next_cron_run_utc(cron_expr, now_dt).ok_or_else(|| { @@ -56,19 +100,23 @@ impl Database { let next = next.to_rfc3339(); 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)", + (id, chat_session_id, workspace_id, create_new_session, kind, name, prompt, + cron_expr, recurring, enabled, created_at, updated_at, next_fire_at, + backend_id, model) + VALUES (?1, ?2, ?3, ?4, 'cron', ?5, ?6, ?7, ?8, 1, ?9, ?9, ?10, ?11, ?12)", params![ id, chat_session_id, workspace_id, + create_new_session as i64, name.filter(|s| !s.trim().is_empty()), prompt, 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)? @@ -81,10 +129,10 @@ impl Database { ) -> Result, rusqlite::Error> { self.conn .query_row( - "SELECT id, chat_session_id, workspace_id, kind, name, prompt, reason, - fire_at, cron_expr, recurring, enabled, created_at, updated_at, + "SELECT id, chat_session_id, workspace_id, create_new_session, kind, name, prompt, + reason, fire_at, cron_expr, recurring, enabled, created_at, updated_at, last_fired_at, next_fire_at, failure_count, last_failed_at, - last_error, disabled_reason + last_error, disabled_reason, backend_id, model FROM agent_scheduled_tasks WHERE id = ?1", params![id], @@ -95,10 +143,10 @@ impl Database { pub fn list_agent_scheduled_tasks(&self) -> Result, rusqlite::Error> { 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, + "SELECT id, chat_session_id, workspace_id, create_new_session, kind, name, prompt, + reason, fire_at, cron_expr, recurring, enabled, created_at, updated_at, last_fired_at, next_fire_at, failure_count, last_failed_at, - last_error, disabled_reason + last_error, disabled_reason, backend_id, model FROM agent_scheduled_tasks ORDER BY enabled DESC, next_fire_at IS NULL, next_fire_at, created_at", )?; @@ -110,10 +158,10 @@ impl Database { chat_session_id: &str, ) -> Result, rusqlite::Error> { 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, + "SELECT id, chat_session_id, workspace_id, create_new_session, kind, name, prompt, + reason, fire_at, cron_expr, recurring, enabled, created_at, updated_at, last_fired_at, next_fire_at, failure_count, last_failed_at, - last_error, disabled_reason + last_error, disabled_reason, 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", @@ -128,10 +176,10 @@ impl Database { ) -> Result, rusqlite::Error> { let now = now.to_rfc3339(); let mut stmt = self.conn.prepare( - "SELECT t.id, t.chat_session_id, t.workspace_id, t.kind, t.name, t.prompt, t.reason, - t.fire_at, t.cron_expr, t.recurring, t.enabled, t.created_at, t.updated_at, - t.last_fired_at, t.next_fire_at, t.failure_count, t.last_failed_at, - t.last_error, t.disabled_reason + "SELECT t.id, t.chat_session_id, t.workspace_id, t.create_new_session, t.kind, t.name, + t.prompt, t.reason, t.fire_at, t.cron_expr, t.recurring, t.enabled, + t.created_at, t.updated_at, t.last_fired_at, t.next_fire_at, t.failure_count, + t.last_failed_at, t.last_error, t.disabled_reason, t.backend_id, t.model FROM agent_scheduled_tasks t JOIN workspaces w ON w.id = t.workspace_id WHERE t.enabled = 1 @@ -339,30 +387,33 @@ impl Database { } fn parse_scheduled_task_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let kind_raw: String = row.get(3)?; + let kind_raw: String = row.get(4)?; let kind = kind_raw.parse().map_err(|e: String| { - rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, e.into()) + rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, e.into()) })?; Ok(ScheduledTask { id: row.get(0)?, chat_session_id: row.get(1)?, workspace_id: row.get(2)?, + create_new_session: row.get::<_, i64>(3)? != 0, kind, - name: row.get(4)?, - prompt: row.get(5)?, - reason: row.get(6)?, - fire_at: row.get(7)?, - cron_expr: row.get(8)?, - recurring: row.get::<_, i64>(9)? != 0, - enabled: row.get::<_, i64>(10)? != 0, - created_at: row.get(11)?, - updated_at: row.get(12)?, - last_fired_at: row.get(13)?, - next_fire_at: row.get(14)?, - failure_count: row.get(15)?, - last_failed_at: row.get(16)?, - last_error: row.get(17)?, - disabled_reason: row.get(18)?, + name: row.get(5)?, + prompt: row.get(6)?, + reason: row.get(7)?, + fire_at: row.get(8)?, + cron_expr: row.get(9)?, + recurring: row.get::<_, i64>(10)? != 0, + enabled: row.get::<_, i64>(11)? != 0, + created_at: row.get(12)?, + updated_at: row.get(13)?, + last_fired_at: row.get(14)?, + next_fire_at: row.get(15)?, + failure_count: row.get(16)?, + last_failed_at: row.get(17)?, + last_error: row.get(18)?, + disabled_reason: row.get(19)?, + backend_id: row.get(20)?, + model: row.get(21)?, }) } @@ -383,10 +434,18 @@ mod tests { let fire_at = Utc::now() + chrono::Duration::minutes(5); let task = db - .create_agent_wakeup(&session.id, fire_at, "check the build", Some("build")) + .create_agent_wakeup( + &ScheduleTarget::Session(session.id.clone()), + fire_at, + "check the build", + Some("build"), + None, + None, + ) .unwrap(); assert_eq!(task.kind, ScheduledTaskKind::Wakeup); - assert_eq!(task.chat_session_id, session.id); + assert_eq!(task.chat_session_id.as_deref(), Some(session.id.as_str())); + assert!(!task.create_new_session); assert!(task.enabled); let rows = db.list_agent_scheduled_tasks().unwrap(); @@ -404,7 +463,15 @@ mod tests { let session: ChatSession = db.create_chat_session(&ws.id).unwrap(); let task = db - .create_agent_cron_task(&session.id, Some("hourly"), "0 * * * *", "check", true) + .create_agent_cron_task( + &ScheduleTarget::Session(session.id.clone()), + Some("hourly"), + "0 * * * *", + "check", + true, + None, + None, + ) .unwrap(); assert_eq!(task.kind, ScheduledTaskKind::Cron); assert_eq!(task.name.as_deref(), Some("hourly")); @@ -422,10 +489,26 @@ mod tests { let session_b = db.create_chat_session(&ws.id).unwrap(); let task_a = db - .create_agent_cron_task(&session_a.id, Some("same-name"), "0 * * * *", "a", true) + .create_agent_cron_task( + &ScheduleTarget::Session(session_a.id.clone()), + Some("same-name"), + "0 * * * *", + "a", + true, + None, + None, + ) .unwrap(); let task_b = db - .create_agent_cron_task(&session_b.id, None, "0 * * * *", "b", true) + .create_agent_cron_task( + &ScheduleTarget::Session(session_b.id.clone()), + None, + "0 * * * *", + "b", + true, + None, + None, + ) .unwrap(); let rows = db @@ -458,10 +541,12 @@ mod tests { let now = Utc::now(); let task = db .create_agent_wakeup( - &session.id, + &ScheduleTarget::Session(session.id.clone()), now - chrono::Duration::minutes(1), "check", None, + None, + None, ) .unwrap(); @@ -487,14 +572,24 @@ mod tests { let now = Utc::now(); let wakeup = db .create_agent_wakeup( - &session.id, + &ScheduleTarget::Session(session.id.clone()), now - chrono::Duration::minutes(1), "check", None, + None, + None, ) .unwrap(); let cron = db - .create_agent_cron_task(&session.id, Some("hourly"), "0 * * * *", "cron", true) + .create_agent_cron_task( + &ScheduleTarget::Session(session.id.clone()), + Some("hourly"), + "0 * * * *", + "cron", + true, + None, + None, + ) .unwrap(); db.conn .execute( @@ -543,7 +638,15 @@ mod tests { db.insert_workspace(&ws).unwrap(); let session = db.create_chat_session(&ws.id).unwrap(); let task = db - .create_agent_cron_task(&session.id, Some("daily"), "0 9 * * *", "check", true) + .create_agent_cron_task( + &ScheduleTarget::Session(session.id.clone()), + Some("daily"), + "0 9 * * *", + "check", + true, + None, + None, + ) .unwrap(); assert_eq!( @@ -566,4 +669,69 @@ mod tests { Some("workspace_archived") ); } + + #[test] + fn new_session_target_stores_workspace_without_session() { + let db = Database::open_in_memory().unwrap(); + let repo = make_repo("repo", "/tmp/repo", "repo"); + db.insert_repository(&repo).unwrap(); + let mut ws = make_workspace("ws", &repo.id, "work"); + // The due-query gates on an active workspace with a worktree. + ws.worktree_path = Some("/tmp/work".into()); + db.insert_workspace(&ws).unwrap(); + + // No chat session exists; the task targets the workspace and the + // scheduler will create a session per fire. + let task = db + .create_agent_cron_task( + &ScheduleTarget::NewSessionInWorkspace(ws.id.clone()), + Some("nightly"), + "0 9 * * *", + "review open PRs", + true, + None, + None, + ) + .unwrap(); + assert!(task.chat_session_id.is_none()); + assert!(task.create_new_session); + assert_eq!(task.workspace_id, ws.id); + + // Round-trips through the row parser. + let listed = db.get_agent_scheduled_task(&task.id).unwrap().unwrap(); + assert!(listed.chat_session_id.is_none()); + assert!(listed.create_new_session); + assert_eq!(listed.workspace_id, ws.id); + + // A new-session task in an active workspace with a worktree is due. + db.conn + .execute( + "UPDATE agent_scheduled_tasks SET next_fire_at = ?2 WHERE id = ?1", + params![ + task.id, + (Utc::now() - chrono::Duration::minutes(1)).to_rfc3339() + ], + ) + .unwrap(); + let due = db.due_agent_scheduled_tasks(Utc::now()).unwrap(); + assert_eq!(due.len(), 1); + assert!(due[0].create_new_session); + } + + #[test] + fn new_session_target_rejects_unknown_workspace() { + let db = Database::open_in_memory().unwrap(); + // No workspace inserted; a new-session task that could never fire must + // be refused rather than persisted as a dangling row. + let err = db.create_agent_wakeup( + &ScheduleTarget::NewSessionInWorkspace("ghost-ws".into()), + Utc::now() + chrono::Duration::minutes(5), + "hi", + None, + None, + None, + ); + assert!(err.is_err()); + assert!(db.list_agent_scheduled_tasks().unwrap().is_empty()); + } } diff --git a/src/db/test_support.rs b/src/db/test_support.rs index b156ef3f5..a8a48439c 100644 --- a/src/db/test_support.rs +++ b/src/db/test_support.rs @@ -82,5 +82,6 @@ pub(crate) fn make_chat_msg( output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } diff --git a/src/fork.rs b/src/fork.rs index 463758c38..8b0c6b6a8 100644 --- a/src/fork.rs +++ b/src/fork.rs @@ -371,6 +371,7 @@ fn copy_history( output_tokens: msg.output_tokens, cache_read_tokens: msg.cache_read_tokens, cache_creation_tokens: msg.cache_creation_tokens, + scheduled_task_id: msg.scheduled_task_id.clone(), }; db.insert_chat_message(&copied)?; } @@ -677,6 +678,7 @@ mod tests { output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, } } @@ -1218,6 +1220,7 @@ mod tests { output_tokens: None, cache_read_tokens: None, cache_creation_tokens: None, + scheduled_task_id: None, }) .unwrap(); db.insert_checkpoint(&ConversationCheckpoint { diff --git a/src/migrations/20260521143000_agent_scheduled_tasks_backend.sql b/src/migrations/20260521143000_agent_scheduled_tasks_backend.sql new file mode 100644 index 000000000..ad1ee359d --- /dev/null +++ b/src/migrations/20260521143000_agent_scheduled_tasks_backend.sql @@ -0,0 +1,16 @@ +-- Capture which agent backend (and optional model) a scheduled task was +-- created under, so the scheduler dispatches the firing turn to the right +-- runtime instead of resolving to the global default backend. +-- +-- Background: `dispatch_prompt_to_session` calls `send_chat_message` with +-- `backend_id: None`, which `resolve_backend_request_defaults` resolves to +-- the `default_agent_backend` app setting (defaults to "anthropic"). So a +-- cron created from a Codex or Pi chat used to fire on Claude instead of +-- the chat's actual backend. +-- +-- Both columns are nullable on purpose: rows created before this migration, +-- and agent-callable scheduling that opts not to pin a backend, keep the +-- legacy global-default behavior. + +ALTER TABLE agent_scheduled_tasks ADD COLUMN backend_id TEXT; +ALTER TABLE agent_scheduled_tasks ADD COLUMN model TEXT; diff --git a/src/migrations/20260603151107_agent_scheduled_tasks_new_session.sql b/src/migrations/20260603151107_agent_scheduled_tasks_new_session.sql new file mode 100644 index 000000000..d43d94aec --- /dev/null +++ b/src/migrations/20260603151107_agent_scheduled_tasks_new_session.sql @@ -0,0 +1,65 @@ +-- Support scheduled tasks that create a FRESH chat session in their target +-- workspace at fire time, instead of always reusing one existing session. +-- +-- Two changes: +-- 1. `chat_session_id` becomes NULLABLE. A `create_new_session` task has no +-- session at schedule time — the scheduler makes one in `workspace_id` +-- each time it fires (so a recurring cron gets a clean session per run). +-- 2. New `create_new_session` flag (0 = reuse `chat_session_id`, the legacy +-- behavior; 1 = create a new session in `workspace_id` on each fire). +-- +-- SQLite cannot drop a NOT NULL constraint in place, so we rebuild the table. +-- Nothing references `agent_scheduled_tasks` (it only points OUT at +-- chat_sessions / workspaces), so the create-copy-drop-rename is safe with +-- foreign_keys=ON: copied rows preserve valid references and no child table +-- is orphaned. All existing rows are reuse-mode (create_new_session = 0). + +CREATE TABLE agent_scheduled_tasks_new ( + id TEXT PRIMARY KEY, + chat_session_id TEXT REFERENCES chat_sessions(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + create_new_session INTEGER NOT NULL DEFAULT 0, + kind TEXT NOT NULL CHECK (kind IN ('wakeup', 'cron')), + name TEXT, + prompt TEXT NOT NULL, + reason TEXT, + fire_at TEXT, + cron_expr TEXT, + recurring INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + last_fired_at TEXT, + next_fire_at TEXT, + backend_id TEXT, + model TEXT, + failure_count INTEGER NOT NULL DEFAULT 0, + last_failed_at TEXT, + last_error TEXT, + disabled_reason TEXT +); + +INSERT INTO agent_scheduled_tasks_new ( + id, chat_session_id, workspace_id, create_new_session, kind, name, prompt, reason, + fire_at, cron_expr, recurring, enabled, created_at, updated_at, last_fired_at, + next_fire_at, backend_id, model, failure_count, last_failed_at, last_error, disabled_reason +) +SELECT + id, chat_session_id, workspace_id, 0, kind, name, prompt, reason, + fire_at, cron_expr, recurring, enabled, created_at, updated_at, last_fired_at, + next_fire_at, backend_id, model, failure_count, last_failed_at, last_error, disabled_reason +FROM agent_scheduled_tasks; + +DROP TABLE agent_scheduled_tasks; + +ALTER TABLE agent_scheduled_tasks_new RENAME TO agent_scheduled_tasks; + +CREATE INDEX idx_agent_scheduled_tasks_next_fire + ON agent_scheduled_tasks(enabled, next_fire_at); + +CREATE INDEX idx_agent_scheduled_tasks_session + ON agent_scheduled_tasks(chat_session_id, kind); + +CREATE UNIQUE INDEX idx_agent_scheduled_tasks_name + ON agent_scheduled_tasks(name) + WHERE name IS NOT NULL; diff --git a/src/migrations/20260603151108_chat_message_scheduled_task.sql b/src/migrations/20260603151108_chat_message_scheduled_task.sql new file mode 100644 index 000000000..27d2c50f6 --- /dev/null +++ b/src/migrations/20260603151108_chat_message_scheduled_task.sql @@ -0,0 +1,6 @@ +-- Mark chat messages that a scheduled task injected, so the chat surface can +-- render a "Scheduled" affordance on the triggering user prompt. +-- +-- Nullable, no foreign key: the referenced task may be deleted later, but the +-- message should keep its provenance marker. NULL for every normal prompt. +ALTER TABLE chat_messages ADD COLUMN scheduled_task_id TEXT; diff --git a/src/migrations/mod.rs b/src/migrations/mod.rs index 8d6c88dac..112f21eef 100644 --- a/src/migrations/mod.rs +++ b/src/migrations/mod.rs @@ -259,9 +259,24 @@ pub const MIGRATIONS: &[Migration] = &[ sql: include_str!("20260521062827_checkpoint_files_dedup.sql"), legacy_version: None, }, + Migration { + id: "20260521143000_agent_scheduled_tasks_backend", + sql: include_str!("20260521143000_agent_scheduled_tasks_backend.sql"), + legacy_version: None, + }, Migration { id: "20260521212019_agent_scheduled_task_failures", sql: include_str!("20260521212019_agent_scheduled_task_failures.sql"), legacy_version: None, }, + Migration { + id: "20260603151107_agent_scheduled_tasks_new_session", + sql: include_str!("20260603151107_agent_scheduled_tasks_new_session.sql"), + legacy_version: None, + }, + Migration { + id: "20260603151108_chat_message_scheduled_task", + sql: include_str!("20260603151108_chat_message_scheduled_task.sql"), + legacy_version: None, + }, ]; diff --git a/src/model/chat_message.rs b/src/model/chat_message.rs index 092af99b4..8e29eb03b 100644 --- a/src/model/chat_message.rs +++ b/src/model/chat_message.rs @@ -67,4 +67,10 @@ pub struct ChatMessage { /// Per-message cache-creation input tokens (maps to /// `cache_creation_input_tokens` in the Anthropic API). NULL for historical rows. pub cache_creation_tokens: Option, + /// Id of the scheduled task (`agent_scheduled_tasks.id`) that injected this + /// message, when it was fired by the scheduler rather than typed by the + /// user. `None` for ordinary prompts. Drives the "Scheduled" affordance on + /// the triggering user message; not a foreign key (the task may be deleted + /// while its history remains). + pub scheduled_task_id: Option, } diff --git a/src/scheduling.rs b/src/scheduling.rs index b6ebba6c4..7066b7710 100644 --- a/src/scheduling.rs +++ b/src/scheduling.rs @@ -30,11 +30,29 @@ impl std::str::FromStr for ScheduledTaskKind { } } +/// Where a scheduled task dispatches when it fires. Chosen at creation and +/// recorded on the row (`chat_session_id` + `create_new_session`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScheduleTarget { + /// Reuse an existing chat session; its workspace is derived at creation. + Session(String), + /// Create a fresh chat session in this workspace each time the task fires + /// (so a recurring cron gets a clean session per run). + NewSessionInWorkspace(String), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledTask { pub id: String, - pub chat_session_id: String, + /// Target session for reuse-mode tasks. `None` when `create_new_session` + /// is true — those rows have no session until the scheduler makes a fresh + /// one in `workspace_id` at fire time. + pub chat_session_id: Option, pub workspace_id: String, + /// When true, the scheduler creates a brand-new chat session in + /// `workspace_id` each time the task fires (so a recurring cron gets a + /// clean session per run) instead of dispatching into `chat_session_id`. + pub create_new_session: bool, pub kind: ScheduledTaskKind, pub name: Option, pub prompt: String, @@ -51,6 +69,16 @@ pub struct ScheduledTask { pub last_failed_at: Option, pub last_error: Option, pub disabled_reason: 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/slash_commands.rs b/src/slash_commands.rs index 2d814698f..981c0fb47 100644 --- a/src/slash_commands.rs +++ b/src/slash_commands.rs @@ -218,6 +218,22 @@ pub fn native_command_registry() -> Vec { argument_hint: Some("[extra guidance]".to_string()), kind: Some(NativeKind::PromptExpansion), }); + commands.push(SlashCommand { + name: "loop".to_string(), + description: "Schedule a recurring cron routine on this chat session".to_string(), + source: "builtin".to_string(), + aliases: Vec::new(), + argument_hint: Some(" ".to_string()), + kind: Some(NativeKind::LocalAction), + }); + commands.push(SlashCommand { + name: "schedule".to_string(), + description: "Schedule a one-shot wakeup on this chat session".to_string(), + source: "builtin".to_string(), + aliases: Vec::new(), + argument_hint: Some("[] []".to_string()), + kind: Some(NativeKind::LocalAction), + }); commands } diff --git a/src/ui/src/App.tsx b/src/ui/src/App.tsx index 480652eb0..51bcbb384 100644 --- a/src/ui/src/App.tsx +++ b/src/ui/src/App.tsx @@ -937,6 +937,11 @@ function App() { store.addToast(msg); }); + // Scheduled tasks (loops + wakeups): load the current list. They share + // the source-of-truth list shown by Settings → Automation and the + // sidebar's Loops-and-Schedules view. + void useAppStore.getState().loadScheduledTasks(); + return () => { isActive = false; window.clearInterval(discoveredServersPollId); diff --git a/src/ui/src/components/chat/ChatPanel.module.css b/src/ui/src/components/chat/ChatPanel.module.css index 24c880c0a..f55050865 100644 --- a/src/ui/src/components/chat/ChatPanel.module.css +++ b/src/ui/src/components/chat/ChatPanel.module.css @@ -355,6 +355,21 @@ flex-shrink: 0; } +/* Marks a user prompt that the scheduler injected (vs. one the user typed), + so it's clear what triggered the agent's work. */ +.scheduledBadge { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 8px; + background: var(--accent-bg); + color: var(--accent-primary); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .role_Assistant .roleLabel { display: none; } diff --git a/src/ui/src/components/chat/ChatPanel.tsx b/src/ui/src/components/chat/ChatPanel.tsx index dbc484980..051a4ad00 100644 --- a/src/ui/src/components/chat/ChatPanel.tsx +++ b/src/ui/src/components/chat/ChatPanel.tsx @@ -651,6 +651,9 @@ export function ChatPanel() { ); }, workspaceId, + sessionId, + openScheduler: (prefill) => + useAppStore.getState().openScheduler(prefill), agentStatus: agentStatusLabel, selectedModel: currentModel, selectedModelProvider: currentModelProvider, diff --git a/src/ui/src/components/chat/MessagesWithTurns.tsx b/src/ui/src/components/chat/MessagesWithTurns.tsx index 77e156995..7373b403d 100644 --- a/src/ui/src/components/chat/MessagesWithTurns.tsx +++ b/src/ui/src/components/chat/MessagesWithTurns.tsx @@ -870,7 +870,17 @@ export const MessagesWithTurns = memo(function MessagesWithTurns({ }`} > {msg.role === "User" && ( -
{t("you_label")}
+
+ {t("you_label")} + {msg.scheduled_task_id && ( + + {t("scheduled_badge")} + + )} +
)} {msg.role === "User" && msg.content.length > 0 && ( ({ + scheduleWakeup: vi.fn<(args: Record) => Promise>( + () => Promise.resolve({}), + ), + createCronRoutine: vi.fn<(args: Record) => Promise>( + () => Promise.resolve({}), + ), +})); +vi.mock("../../services/tauri", async (importActual) => ({ + ...(await importActual()), + scheduleWakeup: schedulerMocks.scheduleWakeup, + createCronRoutine: schedulerMocks.createCronRoutine, +})); import { CONFIG_SECTIONS, NATIVE_HANDLERS, @@ -33,6 +50,13 @@ function makeCtx(overrides: Partial = {}): NativeCommandCo openUsageSettingsExternal: vi.fn<() => void>(), openReleaseNotes: vi.fn<() => void>(), workspaceId: "ws-1", + sessionId: "sess-1", + openScheduler: vi.fn<(prefill?: { + sessionId?: string; + prompt?: string; + fireAt?: string; + cronExpr?: string; + }) => void>(), agentStatus: "Idle", selectedModel: "opus", selectedModelProvider: "anthropic", @@ -1617,3 +1641,134 @@ describe("repo-bootstrap picker filtering", () => { expect(NATIVE_HANDLERS.find((h) => h.name === "init")!.aliases).toEqual([]); }); }); + +describe("/loop handler", () => { + beforeEach(() => { + schedulerMocks.createCronRoutine.mockClear(); + schedulerMocks.scheduleWakeup.mockClear(); + }); + + const loop = () => NATIVE_HANDLERS.find((h) => h.name === "loop")!; + + it.each([ + ["5m run the tests", "*/5 * * * *"], + ["1m ping", "*/1 * * * *"], + ["30m ping", "*/30 * * * *"], + ["1h ping", "0 * * * *"], + ["2h ping", "0 */2 * * *"], + ["12h ping", "0 */12 * * *"], + ["1d ping", "0 0 * * *"], + ])("translates %s to a cron routine (%s)", async (args, cron) => { + const ctx = makeCtx(); + const res = await loop().execute(ctx, args); + expect(res.kind).toBe("handled"); + expect(schedulerMocks.createCronRoutine).toHaveBeenCalledTimes(1); + expect(schedulerMocks.createCronRoutine).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sess-1", + cronExpr: cron, + recurring: true, + }), + ); + }); + + it.each(["30s do it", "7m do it", "5h do it", "2d do it"])( + "opens the dialog in cron mode for non-cron interval %s", + async (args) => { + const ctx = makeCtx(); + const res = await loop().execute(ctx, args); + expect(res.kind).toBe("handled"); + expect(schedulerMocks.createCronRoutine).not.toHaveBeenCalled(); + expect(ctx.openScheduler).toHaveBeenCalledWith( + expect.objectContaining({ mode: "cron" }), + ); + }, + ); + + it("requires an active session", async () => { + const ctx = makeCtx({ sessionId: null }); + await loop().execute(ctx, "5m do it"); + expect(schedulerMocks.createCronRoutine).not.toHaveBeenCalled(); + expect(ctx.addLocalMessage).toHaveBeenCalled(); + }); +}); + +describe("/schedule handler", () => { + beforeEach(() => { + schedulerMocks.scheduleWakeup.mockClear(); + schedulerMocks.createCronRoutine.mockClear(); + }); + + const schedule = () => NATIVE_HANDLERS.find((h) => h.name === "schedule")!; + + it("opens the create dialog with no args", async () => { + const ctx = makeCtx(); + await schedule().execute(ctx, ""); + expect(schedulerMocks.scheduleWakeup).not.toHaveBeenCalled(); + expect(ctx.openScheduler).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "sess-1", mode: "wakeup" }), + ); + }); + + it("fires a wakeup inline for a leading ISO timestamp + prompt", async () => { + const ctx = makeCtx(); + await schedule().execute(ctx, "2026-06-01T09:00 cut the release"); + expect(schedulerMocks.scheduleWakeup).toHaveBeenCalledTimes(1); + const arg = schedulerMocks.scheduleWakeup.mock.calls[0][0] as { + fireAt: string; + prompt: string; + }; + expect(arg.prompt).toBe("cut the release"); + // Parsed in LOCAL time, not UTC midnight. + const d = new Date(arg.fireAt); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(5); // June + expect(d.getDate()).toBe(1); + expect(d.getHours()).toBe(9); + expect(d.getMinutes()).toBe(0); + }); + + it("accepts a space-separated date and time as two tokens without leaking the time into the prompt", async () => { + const ctx = makeCtx(); + await schedule().execute(ctx, "2026-06-01 09:30 cut the release"); + expect(schedulerMocks.scheduleWakeup).toHaveBeenCalledTimes(1); + const arg = schedulerMocks.scheduleWakeup.mock.calls[0][0] as { + fireAt: string; + prompt: string; + }; + expect(arg.prompt).toBe("cut the release"); + const d = new Date(arg.fireAt); + expect(d.getHours()).toBe(9); + expect(d.getMinutes()).toBe(30); + }); + + it("treats a bare date as local midnight", async () => { + const ctx = makeCtx(); + await schedule().execute(ctx, "2026-06-01 do the thing"); + expect(schedulerMocks.scheduleWakeup).toHaveBeenCalledTimes(1); + const arg = schedulerMocks.scheduleWakeup.mock.calls[0][0] as { + fireAt: string; + }; + const d = new Date(arg.fireAt); + expect(d.getDate()).toBe(1); + expect(d.getHours()).toBe(0); + }); + + it("opens the dialog (prefilled time) when a timestamp has no prompt", async () => { + const ctx = makeCtx(); + await schedule().execute(ctx, "2026-06-01T09:00"); + expect(schedulerMocks.scheduleWakeup).not.toHaveBeenCalled(); + expect(ctx.openScheduler).toHaveBeenCalledWith( + expect.objectContaining({ mode: "wakeup", fireAt: expect.any(String) }), + ); + }); + + it("treats a non-timestamp arg as the whole prompt", async () => { + const ctx = makeCtx(); + await schedule().execute(ctx, "write the weekly status update"); + expect(schedulerMocks.scheduleWakeup).not.toHaveBeenCalled(); + expect(ctx.openScheduler).toHaveBeenCalledWith( + expect.objectContaining({ prompt: "write the weekly status update" }), + ); + }); +}); diff --git a/src/ui/src/components/chat/nativeSlashCommands.ts b/src/ui/src/components/chat/nativeSlashCommands.ts index 3eb2f8e8a..10bfcfef6 100644 --- a/src/ui/src/components/chat/nativeSlashCommands.ts +++ b/src/ui/src/components/chat/nativeSlashCommands.ts @@ -1,5 +1,10 @@ import type { PluginSettingsIntent } from "../../types/plugins"; -import type { NativeSlashKind, SlashCommand } from "../../services/tauri"; +import { + type NativeSlashKind, + type SlashCommand, + scheduleWakeup, + createCronRoutine, +} from "../../services/tauri"; import type { PermissionLevel } from "../../stores/useAppStore"; import { parsePluginSlashCommand } from "./pluginSlashCommand"; import { buildModelRegistry, resolveModelSelection } from "./modelRegistry"; @@ -54,6 +59,19 @@ export interface NativeCommandContext { // -- Per-workspace state read by workspace-control commands // (/clear, /plan, /model, /permissions, /status). -- workspaceId: string | null; + /** Active chat session id (read by `/loop` and `/schedule` so a new + * scheduled task is bound to the same session the user typed in). */ + sessionId: string | null; + /** Open the Loops and Schedules view, optionally prefilling the + * create-task modal (used by `/schedule` and `/loop` when their args + * can't be turned into a concrete task on the spot). */ + openScheduler: (prefill?: { + sessionId?: string; + prompt?: string; + fireAt?: string; + cronExpr?: string; + mode?: "wakeup" | "cron"; + }) => void; agentStatus: string | null; selectedModel: string; selectedModelProvider: string; @@ -723,6 +741,195 @@ const initHandler: NativeHandler = { }, }; +/** Map a friendly interval token (e.g. `5m`, `1h`, `1d`) to a standard + * 5-field cron expression. Returns `null` for sub-minute or odd intervals + * that cron can't represent cleanly. */ +function intervalToCron(token: string): string | null { + const m = /^(\d+)(s|m|h|d)$/i.exec(token.trim()); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + const unit = m[2].toLowerCase(); + if (unit === "s") return null; // sub-minute — cron can't express + if (unit === "m") { + if (n >= 60) return null; + if (60 % n !== 0) return null; // odd intervals don't tile the hour + return `*/${n} * * * *`; + } + if (unit === "h") { + if (n >= 24) return null; + if (24 % n !== 0) return null; + return n === 1 ? `0 * * * *` : `0 */${n} * * *`; + } + // unit === "d" + if (n !== 1) return null; + return `0 0 * * *`; +} + +const LOOP_USAGE = + "/loop: usage — `/loop ` (e.g. `/loop 5m run the tests`). The interval must evenly divide its unit: minutes that divide an hour (e.g. 1m, 5m, 15m, 30m), hours that divide a day (e.g. 1h, 2h, 6h, 12h), or 1d. For anything else, write a cron expression in the New scheduled task dialog."; + +const loopHandler: NativeHandler = { + name: "loop", + aliases: [], + kind: "local_action", + execute: async (ctx, args) => { + const handled = { kind: "handled" as const, canonicalName: "loop" }; + if (!ctx.workspaceId || !ctx.sessionId) { + ctx.addLocalMessage("/loop: no active workspace"); + return handled; + } + const tokens = args.trim().split(/\s+/); + if (tokens.length === 0 || tokens[0] === "") { + ctx.addLocalMessage(LOOP_USAGE); + return handled; + } + const cronExpr = intervalToCron(tokens[0]); + const prompt = tokens.slice(1).join(" ").trim(); + if (!cronExpr) { + // Interval can't be expressed as cron — open the create dialog in cron + // mode so the user can write the expression directly. + ctx.openScheduler({ + sessionId: ctx.sessionId, + prompt: prompt || undefined, + mode: "cron", + }); + ctx.addLocalMessage(LOOP_USAGE); + return handled; + } + if (!prompt) { + ctx.addLocalMessage(LOOP_USAGE); + 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, + }); + } catch (e) { + ctx.addLocalMessage(`/loop failed: ${String(e)}`); + } + return handled; + }, +}; + +const SCHEDULE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const SCHEDULE_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/; +const SCHEDULE_TIME_RE = /^\d{2}:\d{2}(:\d{2})?$/; + +/** + * Parse a leading schedule time from the args tokens, in **local** time. + * + * Accepts `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM[:SS]`, or a date and time as two + * whitespace-separated tokens (`YYYY-MM-DD HH:MM[:SS]`). Returns the resolved + * `Date` plus how many tokens it consumed, or `null` when the first token + * isn't a date. + * + * Built from components via `new Date(y, mo-1, d, h, mi, s)` rather than + * `new Date(string)` on purpose: a bare `YYYY-MM-DD` string parses as UTC + * midnight (wrong local day in most zones), and the space-separated form isn't + * reliably supported across JS engines / WebViews. + */ +function parseLeadingSchedule( + tokens: string[], +): { date: Date; consumed: number } | null { + const first = tokens[0] ?? ""; + let datePart: string; + let timePart = ""; + let consumed: number; + if (SCHEDULE_DATETIME_RE.test(first)) { + const [d, t] = first.split(/[T ]/); + datePart = d; + timePart = t; + consumed = 1; + } else if (SCHEDULE_DATE_RE.test(first)) { + datePart = first; + consumed = 1; + // A bare time as the next token completes the timestamp ("2026-06-01 09:00"). + if (tokens[1] && SCHEDULE_TIME_RE.test(tokens[1])) { + timePart = tokens[1]; + consumed = 2; + } + } else { + return null; + } + const [y, mo, da] = datePart.split("-").map(Number); + let h = 0; + let mi = 0; + let s = 0; + if (timePart) { + const [th, tm, ts] = timePart.split(":").map(Number); + h = th; + mi = tm; + s = ts ?? 0; + } + const date = new Date(y, mo - 1, da, h, mi, s, 0); + if (Number.isNaN(date.getTime())) return null; + return { date, consumed }; +} + +const scheduleHandler: NativeHandler = { + name: "schedule", + aliases: [], + kind: "local_action", + execute: async (ctx, args) => { + const handled = { kind: "handled" as const, canonicalName: "schedule" }; + const trimmed = args.trim(); + if (!trimmed) { + // No args — open the create dialog (one-shot/wakeup by default). + ctx.openScheduler({ sessionId: ctx.sessionId ?? undefined, mode: "wakeup" }); + return handled; + } + const tokens = trimmed.split(/\s+/); + const parsed = parseLeadingSchedule(tokens); + if (parsed) { + const rest = tokens.slice(parsed.consumed).join(" ").trim(); + if (ctx.sessionId && rest) { + // Inline schedule: known time, known prompt, known session — fire the + // wakeup directly. Pin the toolbar's backend + model so the wakeup + // 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.date.toISOString(), + prompt: rest, + backendId: ctx.selectedModelProvider || undefined, + model: ctx.selectedModel || undefined, + }); + } catch (e) { + ctx.addLocalMessage(`/schedule failed: ${String(e)}`); + } + return handled; + } + // Time parsed but missing prompt or session — open the dialog prefilled. + ctx.openScheduler({ + sessionId: ctx.sessionId ?? undefined, + mode: "wakeup", + prompt: rest || undefined, + fireAt: parsed.date.toISOString(), + }); + return handled; + } + // No leading timestamp — treat all of args as the prompt. + ctx.openScheduler({ + sessionId: ctx.sessionId ?? undefined, + mode: "wakeup", + prompt: trimmed, + }); + return handled; + }, +}; + function formatCommandLine( cmd: SlashCommand, shadowedAliases: ReadonlySet, @@ -915,6 +1122,8 @@ export const NATIVE_HANDLERS: NativeHandler[] = [ statusHandler, helpHandler, initHandler, + loopHandler, + scheduleHandler, ]; /** Resolve a slash command token (no leading `/`) against the native handler table. */ diff --git a/src/ui/src/components/layout/AppLayout.tsx b/src/ui/src/components/layout/AppLayout.tsx index 426bbc0f0..468ecb335 100644 --- a/src/ui/src/components/layout/AppLayout.tsx +++ b/src/ui/src/components/layout/AppLayout.tsx @@ -14,6 +14,7 @@ import { CommandPalette } from "../command-palette/CommandPalette"; import { Dashboard } from "./Dashboard"; import { ModalRouter } from "../modals/ModalRouter"; import { SettingsPage } from "../settings/SettingsPage"; +import { SchedulerPage } from "../scheduler/SchedulerPage"; import { ResizeHandle } from "./ResizeHandle"; import { ToastContainer } from "./Toast"; import { AppTooltip } from "../shared/AppTooltip"; @@ -40,6 +41,7 @@ export function AppLayout() { const terminalHeight = useAppStore((s) => s.terminalHeight); const setTerminalHeight = useAppStore((s) => s.setTerminalHeight); const settingsOpen = useAppStore((s) => s.settingsOpen); + const schedulerOpen = useAppStore((s) => s.schedulerOpen); const fuzzyFinderOpen = useAppStore((s) => s.fuzzyFinderOpen); const commandPaletteOpen = useAppStore((s) => s.commandPaletteOpen); @@ -132,7 +134,12 @@ export function AppLayout() { )}
- {selectedWorkspaceId ? ( + {/* The Loops and Schedules view replaces the workspace/dashboard + content but, like the Dashboard, keeps the sidebar + + PanelHeader + PanelToggles chrome in place. */} + {schedulerOpen ? ( + + ) : selectedWorkspaceId ? ( <>
- {selectedWorkspaceId && ( + {!schedulerOpen && selectedWorkspaceId && ( <> {rightSidebarVisible && ( * { + flex: 1; + min-width: 0; +} diff --git a/src/ui/src/components/modals/CreateScheduledTaskModal.tsx b/src/ui/src/components/modals/CreateScheduledTaskModal.tsx new file mode 100644 index 000000000..eb62d8e25 --- /dev/null +++ b/src/ui/src/components/modals/CreateScheduledTaskModal.tsx @@ -0,0 +1,379 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppStore, selectActiveSessionId } from "../../stores/useAppStore"; +import { + createCronRoutine, + listChatSessions, + scheduleWakeup, +} from "../../services/tauri"; +import { Modal } from "./Modal"; +import shared from "./shared.module.css"; +import styles from "./CreateScheduledTaskModal.module.css"; + +type TaskType = "wakeup" | "cron"; + +/** Sentinel for the session dropdown's "create a fresh session each run" + * choice. Distinct from any real session id. */ +const NEW_SESSION = "__new_session__"; + +/** `Date` -> value for `` (local time, no tz). */ +function toDatetimeLocalValue(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Very-loose 5-field cron validator. The backend has the authoritative + * parser; this just catches obvious typos before the round trip. */ +function looksLikeCron(expr: string): boolean { + const trimmed = expr.trim(); + if (!trimmed) return false; + return trimmed.split(/\s+/).length === 5; +} + +export function CreateScheduledTaskModal() { + const { t } = useTranslation("scheduler"); + const { t: tCommon } = useTranslation("common"); + const closeModal = useAppStore((s) => s.closeModal); + const modalData = useAppStore((s) => s.modalData); + const repositories = useAppStore((s) => s.repositories); + const workspaces = useAppStore((s) => s.workspaces); + const sessionsByWorkspace = useAppStore((s) => s.sessionsByWorkspace); + const sessionsLoadedByWorkspace = useAppStore((s) => s.sessionsLoadedByWorkspace); + const setSessionsForWorkspace = useAppStore((s) => s.setSessionsForWorkspace); + const loadScheduledTasks = useAppStore((s) => s.loadScheduledTasks); + const activeSessionId = useAppStore(selectActiveSessionId); + + const prefillSessionId = + typeof modalData.sessionId === "string" ? (modalData.sessionId as string) : null; + const prefillPrompt = + typeof modalData.prompt === "string" ? (modalData.prompt as string) : ""; + const prefillFireAt = + typeof modalData.fireAt === "string" ? (modalData.fireAt as string) : null; + const prefillCron = + typeof modalData.cronExpr === "string" ? (modalData.cronExpr as string) : ""; + const prefillMode = + modalData.mode === "cron" || modalData.mode === "wakeup" + ? (modalData.mode as TaskType) + : null; + + // Active (non-archived) workspaces for a repo, in sidebar order. + const workspacesForRepo = (repoId: string) => + workspaces + .filter((w) => w.repository_id === repoId && w.status !== "Archived") + .sort((a, b) => a.sort_order - b.sort_order); + + // Resolve the initial repo/workspace once, at mount, from the prefilled + // session (a `/schedule` from a chat), then the currently open workspace, + // then the first available workspace. + const [repoId, setRepoId] = useState(() => { + const s = useAppStore.getState(); + const seedWsId = + (prefillSessionId && + Object.entries(s.sessionsByWorkspace).find(([, list]) => + list.some((cs) => cs.id === prefillSessionId), + )?.[0]) || + s.selectedWorkspaceId || + s.workspaces.find((w) => w.status !== "Archived")?.id || + ""; + return s.workspaces.find((w) => w.id === seedWsId)?.repository_id ?? s.repositories[0]?.id ?? ""; + }); + const [workspaceId, setWorkspaceId] = useState(() => { + const s = useAppStore.getState(); + if (prefillSessionId) { + const hit = Object.entries(s.sessionsByWorkspace).find(([, list]) => + list.some((cs) => cs.id === prefillSessionId), + ); + if (hit) return hit[0]; + } + return ( + s.selectedWorkspaceId || + s.workspaces.find((w) => w.status !== "Archived")?.id || + "" + ); + }); + const [sessionTarget, setSessionTarget] = useState( + () => prefillSessionId ?? activeSessionId ?? NEW_SESSION, + ); + + const [type, setType] = useState( + () => prefillMode ?? (prefillCron ? "cron" : "wakeup"), + ); + const [fireAt, setFireAt] = useState(() => { + if (prefillFireAt) { + const d = new Date(prefillFireAt); + if (!Number.isNaN(d.getTime())) return toDatetimeLocalValue(d); + } + return toDatetimeLocalValue(new Date(Date.now() + 60 * 60 * 1000)); + }); + const [cronExpr, setCronExpr] = useState(prefillCron); + const [name, setName] = useState(""); + const [recurring, setRecurring] = useState(true); + const [prompt, setPrompt] = useState(prefillPrompt); + const [loadingSessions, setLoadingSessions] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Lazy-load the chosen workspace's sessions. SessionTabs only loads the + // open workspace, so unopened ones are blank until we fetch them here. + useEffect(() => { + if (!workspaceId || sessionsLoadedByWorkspace[workspaceId]) return; + let cancelled = false; + setLoadingSessions(true); + listChatSessions(workspaceId) + .then((list) => { + if (!cancelled) setSessionsForWorkspace(workspaceId, list); + }) + .catch(() => { + // Leave the dropdown with just "New session"; submit still works. + }) + .finally(() => { + if (!cancelled) setLoadingSessions(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceId, sessionsLoadedByWorkspace, setSessionsForWorkspace]); + + // Keep the workspace consistent with the selected repo. + useEffect(() => { + const list = workspacesForRepo(repoId); + if (workspaceId && list.some((w) => w.id === workspaceId)) return; + setWorkspaceId(list[0]?.id ?? ""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repoId]); + + const workspaceSessions = useMemo( + () => + (sessionsByWorkspace[workspaceId] ?? []).filter((s) => s.status !== "Archived"), + [sessionsByWorkspace, workspaceId], + ); + + // Once the workspace's sessions are known, drop a stale session target + // (one belonging to a different workspace) down to the first session, or + // to "new session" if the workspace has none. Never clobbers an explicit + // "new session" choice. + useEffect(() => { + if (!workspaceId || !sessionsLoadedByWorkspace[workspaceId]) return; + setSessionTarget((prev) => { + if (prev === NEW_SESSION) return prev; + if (workspaceSessions.some((s) => s.id === prev)) return prev; + return workspaceSessions[0]?.id ?? NEW_SESSION; + }); + }, [workspaceId, workspaceSessions, sessionsLoadedByWorkspace]); + + const repoWorkspaces = workspacesForRepo(repoId); + const usesNewSession = sessionTarget === NEW_SESSION; + const canSubmit = !submitting && !!workspaceId && (usesNewSession || !!sessionTarget); + + const handleSubmit = async () => { + setError(null); + if (!workspaceId) { + setError(t("error_pick_workspace")); + return; + } + if (!usesNewSession && !sessionTarget) { + setError(t("error_pick_session")); + return; + } + if (!prompt.trim()) { + setError(t("error_empty_prompt")); + return; + } + const targetArgs = usesNewSession + ? { workspaceId, createNewSession: true } + : { sessionId: sessionTarget }; + try { + setSubmitting(true); + if (type === "wakeup") { + const when = new Date(fireAt); + if (Number.isNaN(when.getTime())) { + setError(t("error_bad_time")); + setSubmitting(false); + return; + } + await scheduleWakeup({ + ...targetArgs, + fireAt: when.toISOString(), + prompt: prompt.trim(), + }); + } else { + if (!looksLikeCron(cronExpr)) { + setError(t("error_bad_cron")); + setSubmitting(false); + return; + } + await createCronRoutine({ + ...targetArgs, + name: name.trim() || undefined, + cronExpr: cronExpr.trim(), + prompt: prompt.trim(), + recurring, + }); + } + void loadScheduledTasks(); + closeModal(); + } catch (e) { + setError(String(e)); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ {loadingSessions + ? t("loading_sessions") + : usesNewSession + ? t("new_session_hint") + : t("reuse_session_hint")} +
+
+ + {type === "wakeup" ? ( +
+ + setFireAt(e.target.value)} + /> +
+ ) : ( + <> +
+ + setCronExpr(e.target.value)} + placeholder="0 9 * * 1-5" + /> +
{t("cron_expr_hint")}
+
+
+ + setName(e.target.value)} + /> +
+ + + )} + +
+ +