Skip to content
Merged
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
20 changes: 11 additions & 9 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:

| Tool | Purpose |
|---|---|
| `ScheduleWakeup` | Schedule a one-shot wakeup with `delaySeconds` or `fireAt`, plus a prompt and optional reason. |
| `CronCreate` | Create a routine from a standard 5-field cron expression in local time. |
| `CronList` | List persisted wakeups and routines. |
| `CronDelete` | Delete a routine by id or name. |
| `Monitor` | Subscribe to future output lines from a background Bash task instead of polling. |
Agents can schedule their own wakeups and routines through native tools:

| Tool | Purpose | Backends |
|---|---|---|
| `ScheduleWakeup` | Schedule a one-shot wakeup with `delaySeconds` or `fireAt`, plus a prompt and optional reason. | Claude, Codex, Pi |
| `CronCreate` | Create a routine from a standard 5-field cron expression in local time. | Claude, Codex, Pi |
| `CronList` | List persisted wakeups and routines. | Claude, Codex, Pi |
| `CronDelete` | Delete a routine by id or name. | Claude, Codex, Pi |
| `Monitor` | Subscribe to future output lines from a background Bash task instead of polling. | Claude, Codex |

Claude and Codex agents reach these tools over the built-in Claudette MCP bridge. The Pi backend has no MCP bridge, so its scheduling tools are registered natively in the Pi sidecar — `Monitor` is unavailable there. Scheduling stays available regardless of the **Agent Attachments** plugin toggle.

Wakeups and routines survive app restarts. If a fire time passes while Claudette is closed, the scheduler fires the overdue task when the app starts again.

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
40 changes: 39 additions & 1 deletion src-tauri/src/agent_mcp_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ async fn handle_payload(
media_type,
caption,
} => {
// The Claudette MCP server is now injected unconditionally so
// its always-on scheduling tools reach every agent (see the
// wiring in commands/chat/send.rs + remote_control.rs). The
// "Agent Attachments" plugin toggle therefore can't gate the
// server's mere presence anymore — it has to gate the
// send_to_user call itself, here. Without this check,
// disabling the plugin would still let attachments through,
// breaking the Settings contract that turning it off removes
// the tool. Reusing the same `is_builtin_plugin_enabled` read
// the system-prompt nudge already consults.
let db_for_gate = match Database::open(&db_path) {
Ok(db) => db,
Err(err) => {
return BridgeResponse::err(format!("open db: {err}"));
}
};
if !claudette::agent_mcp::is_builtin_plugin_enabled(&db_for_gate, "send_to_user") {
return BridgeResponse::err(
"The Agent Attachments plugin is disabled. Enable it in Settings → \
Plugins to deliver files inline."
.to_string(),
);
}
drop(db_for_gate);
send_attachment(
app,
db_path,
Expand Down Expand Up @@ -189,7 +213,19 @@ fn schedule_wakeup(
Ok(db) => db,
Err(err) => return BridgeResponse::err(format!("open db: {err}")),
};
match db.create_agent_wakeup(&chat_session_id, fire_at, &prompt, reason.as_deref()) {
// Agent-callable scheduling doesn't pin a backend — the cron inherits
// the global default when it fires. Backend pinning is a frontend
// concern (toolbar choice via `/loop` / `/schedule`); the agent itself
// is already running on a backend and doesn't get to choose for the
// fired turn.
match db.create_agent_wakeup(
&chat_session_id,
fire_at,
&prompt,
reason.as_deref(),
None,
None,
) {
Ok(task) => {
app.state::<AppState>().scheduler_notify.notify_waiters();
BridgeResponse::data(
Expand Down Expand Up @@ -220,6 +256,8 @@ fn create_cron(
&cron_expr,
&prompt,
recurring,
None,
None,
) {
Ok(task) => {
app.state::<AppState>().scheduler_notify.notify_waiters();
Expand Down
24 changes: 15 additions & 9 deletions src-tauri/src/commands/chat/remote_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -480,13 +477,18 @@ async fn ensure_persistent_session_for_remote_control(
});
from_config.or_else(|| repo.as_ref().and_then(|r| r.custom_instructions.clone()))
};
let nudge = send_to_user_enabled.then_some(claudette::agent_mcp::SYSTEM_PROMPT_NUDGE);
// Scheduling/Monitor nudge is always on (the MCP server is injected
// unconditionally). `send_to_user` nudge is gated on the Agent
// Attachments plugin toggle. Mirrors the spawn path in
// `commands/chat/send.rs` so the remote-control entry point stays in
// lockstep with normal chat spawns.
let nudge_owned = claudette::agent_mcp::mcp_system_prompt_nudge(send_to_user_enabled);
// Remote-control sessions always launch through Claude CLI, so the
// Claude-Code MCP rule block applies (AskUserQuestion / ExitPlanMode
// exist via the Claudette MCP bridge).
let custom_instructions = claudette::global_prompt::compose_system_prompt(
instructions.as_deref(),
nudge,
nudge_owned.as_deref(),
Some(claudette::agent_mcp::CLAUDE_CODE_MCP_RULES),
);
let level = launch_options.permission_level.as_deref().unwrap_or("full");
Expand Down Expand Up @@ -569,7 +571,13 @@ async fn ensure_persistent_session_for_remote_control(
hook_bridge: None,
extra_claude_flags,
};
let bridge = if send_to_user_enabled {
// Inject the Claudette MCP server unconditionally — it carries the
// always-on scheduling tools (ScheduleWakeup / Cron*) and `Monitor`.
// The `send_to_user_enabled` toggle gates only that tool's nudge
// (above) and the call itself (in `agent_mcp_sink::handle_payload`),
// not the whole server. Same wiring as the spawn / respawn blocks in
// `chat::send::send_chat_message`.
let bridge = {
let (bridge, mcp_with_claudette) = start_bridge_and_inject_mcp(
app,
&state.db_path,
Expand All @@ -580,8 +588,6 @@ async fn ensure_persistent_session_for_remote_control(
.await?;
agent_settings.mcp_config = mcp_with_claudette;
bridge
} else {
start_chat_bridge(app, &state.db_path, &workspace_id, &chat_session_id).await?
};
agent_settings.hook_bridge = Some(build_agent_hook_bridge(&bridge)?);

Expand Down
Loading