Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions site/src/content/docs/features/agent-scheduling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 <interval> <prompt>` 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 <when> <prompt>` 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.
2 changes: 2 additions & 0 deletions site/src/content/docs/features/slash-commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` `<interval> <prompt>` | 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` `[<when>] [<prompt>]` | 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. |
Expand Down
131 changes: 130 additions & 1 deletion src-pi-harness/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PendingTool>;
// 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<string, PendingHostTool>;
activeTurnStartedAt?: number;
// True when Claudette's permission level is `full` — i.e. the user
// ran `/permissions full` and Claudette's tools-for-level resolver
Expand All @@ -66,6 +83,7 @@ const state: HarnessState = {
authStorage: AuthStorage.create(),
modelRegistry: ModelRegistry.create(AuthStorage.create()),
pendingTools: new Map(),
pendingHostTools: new Map(),
bypassPermissions: false,
};

Expand Down Expand Up @@ -280,6 +298,36 @@ function textResult(text: string, details: Record<string, unknown> = {}) {
};
}

/** 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<HostToolResult> {
send({ type: "host_tool", requestId: toolCallId, name, args });
return new Promise<HostToolResult>((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 = [
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -734,10 +834,18 @@ async function grepFallback(root: string, query: string): Promise<string> {
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<string>();
for (const tool of tools) {
Expand All @@ -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 };
}

Expand Down Expand Up @@ -1434,6 +1543,12 @@ async function handle(message: RequestMessage): Promise<void> {
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;
Expand Down Expand Up @@ -1508,6 +1623,20 @@ async function handle(message: RequestMessage): Promise<void> {
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;
Expand Down
3 changes: 3 additions & 0 deletions src-server/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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())?;
Expand Down
1 change: 1 addition & 0 deletions src-server/tests/chat_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
43 changes: 41 additions & 2 deletions src-tauri/src/agent_mcp_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<AppState>().scheduler_notify.notify_waiters();
BridgeResponse::data(
Expand All @@ -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::<AppState>().scheduler_notify.notify_waiters();
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/chat/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;

Expand Down
Loading
Loading