Compare commits

...
Author SHA1 Message Date
abeatrix 9708da1ff3 feat(hub): support agent-created task delivery
Add schedule_task tooling for agents to create scheduled tasks and route results back to the origin session or connector thread. Persist connector delivery metadata and mark scheduled runs in TUI history for easier identification.
2026-07-17 12:30:04 -07:00
abeatrix 4c85eaa7bb feat(desktop): route /team prompts through core runtime
Rewrite desktop `/team` commands as structured user command blocks before sending them to the core runtime. Validate task input and respect the globally disabled Teams tool setting.

Remove legacy agent spawn and team enablement flags from session configuration, and add coverage for prompt rewriting and disabled-tool behavior.
2026-07-16 22:55:38 -07:00
22 changed files with 780 additions and 21 deletions
@@ -228,6 +228,22 @@ export async function getOrCreateSessionId<
sessionId,
metadata: {
transport: input.transport,
// Delivery descriptor for this connector thread. Lets the
// agent-facing schedule_task tool (deliverTo: "connector") post a
// scheduled run's result back into this thread, reusing the same
// per-adapter delivery path as user-typed /schedule.
delivery: {
adapter: input.transport,
threadId: input.thread.id,
bindingKey: input.thread.id,
...(input.thread.channelId
? { channelId: input.thread.channelId }
: {}),
...(threadState.participantKey
? { participantKey: threadState.participantKey }
: {}),
...(input.hookBotUserName ? { userName: input.hookBotUserName } : {}),
},
...input.sessionMetadata,
...(remoteConfigMetadata ?? {}),
...(threadState.participantKey
+1
View File
@@ -352,6 +352,7 @@ function HistoryListContent({
fg={isSel ? palette.textOnSelection : undefined}
flexGrow={1}
>
{row.source === "schedule" ? "⏰ " : ""}
{title}
</text>
{showCost && cost != null && cost > 0 && (
@@ -4,11 +4,26 @@ import {
consumeWorkspaceMetadata,
handleChatSessionCommand,
prewarmWorkspaceMetadata,
rewriteDesktopTeamPrompt,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import type { SidecarContext } from "./types";
describe("rewriteDesktopTeamPrompt", () => {
it("rewrites /team for the core runtime", () => {
expect(rewriteDesktopTeamPrompt("/team inspect the app", new Set())).toBe(
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
);
});
it("rejects /team when the Teams tool is disabled", () => {
expect(() =>
rewriteDesktopTeamPrompt("/team inspect the app", new Set(["teams"])),
).toThrow("Agent teams are disabled");
});
});
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
const update = buildSessionConnectionUpdate({
@@ -6,12 +6,13 @@ import {
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
readGlobalSettings,
type SessionPendingPrompt,
SessionSource,
splitCoreSessionConfig,
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt } from "@cline/shared";
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
import { emitChunk, nowMs, sendEvent } from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import type {
@@ -37,6 +38,31 @@ const workspaceMetadataPromises = new Map<
WorkspaceMetadataCacheEntry
>();
export function rewriteDesktopTeamPrompt(
prompt: string,
disabledTools: ReadonlySet<string> = new Set(
readGlobalSettings().disabledTools ?? [],
),
): string {
const match = /^\/team\b([\s\S]*)$/i.exec(prompt.trim());
if (!match) return prompt;
const task = match[1]?.trim();
if (!task) {
throw new Error(
"Usage: /team <task description>. Starts a team of agents for the given task.",
);
}
if (disabledTools.has("teams")) {
throw new Error(
"Agent teams are disabled. Enable the Teams tool in Customizations → Tools.",
);
}
return formatUserCommandBlock(
`spawn a team of agents for the following task: ${task}`,
"team",
);
}
function getWorkspaceMetadataPromise(
cwd: string,
load: WorkspaceMetadataLoader,
@@ -217,16 +243,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
enableSpawnAgent:
config.enableSpawn ??
config.enableSpawnAgent ??
config.enable_spawn ??
false,
enableAgentTeams:
config.enableTeams ??
config.enableAgentTeams ??
config.enable_teams ??
false,
...(thinking !== undefined ? { thinking } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
@@ -518,6 +534,7 @@ async function handleSend(
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
const runtimePrompt = rewriteDesktopTeamPrompt(prompt);
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
@@ -551,7 +568,7 @@ async function handleSend(
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
await manager.send({
sessionId,
prompt,
prompt: runtimePrompt,
delivery: "queue",
userImages: request.attachments?.userImages,
});
@@ -575,7 +592,7 @@ async function handleSend(
);
const result = await manager.send({
sessionId,
prompt,
prompt: runtimePrompt,
delivery,
userImages: request.attachments?.userImages,
});
@@ -638,6 +638,8 @@ async function listUserInstructionConfigs(
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
const builtinToolCatalog = getCoreBuiltinToolCatalog({
enableSpawnAgent: true,
enableAgentTeams: true,
disabledToolIds: disabledTools,
});
@@ -706,7 +706,7 @@ export function ChatInputBar({
value={editingQueuedPromptValue}
/>
) : (
<div className="line-clamp-2 whitespace-pre-wrap break-words text-xs text-foreground">
<div className="line-clamp-2 whitespace-pre-wrap wrap-break-word text-xs text-foreground">
{item.prompt}
</div>
)}
@@ -1097,7 +1097,7 @@ export function ChatInputBar({
>
<SelectTrigger
aria-label="Thinking level"
className="h-7 min-w-[5.75rem] gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
className="h-7 min-w-23 gap-1.5 border-0 bg-muted px-2 text-[11px] shadow-none data-[size=sm]:h-7 max-[560px]:col-span-2 max-[560px]:col-start-1 max-[560px]:row-start-2"
size="sm"
title={
modelSupportsReasoning === false
@@ -124,8 +124,6 @@ export function normalizeRuntimeConfig(
cwd: normalizedCwd || normalizedWorkspaceRoot,
thinking,
reasoningEffort: thinking === false ? undefined : config.reasoningEffort,
enableSpawn: false,
enableTeams: false,
};
}
@@ -0,0 +1,115 @@
/**
* Origin-session delivery for agent-created scheduled tasks.
*
* When an agent creates a schedule with `deliverTo: "origin_session"`, the
* scheduled run still executes in its own isolated session. On completion, its
* result is fed back into the session that created the schedule as a queued
* follow-up turn, so the main agent can continue working with it.
*
* Delivery is best-effort: if the origin session is not currently active
* (persisted-but-not-in-memory), `runTurn` rejects and we skip rather than
* throw. Appending into a persisted-but-inactive session is a follow-up.
*/
import type { RuntimeHost } from "../../runtime/host/runtime-host";
interface LooseMessage {
role?: string;
content?: unknown;
}
function extractText(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (
part &&
typeof part === "object" &&
"text" in part &&
typeof (part as { text?: unknown }).text === "string"
) {
return (part as { text: string }).text;
}
return "";
})
.join("");
}
return "";
}
function extractLastAssistantText(
messages: readonly LooseMessage[],
): string | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role === "assistant") {
const text = extractText(messages[i]?.content).trim();
if (text) {
return text;
}
}
}
return undefined;
}
export interface OriginSessionDeliveryInput {
host: RuntimeHost;
/** Session that created the schedule (delivery target). */
originSessionId: string;
/** Session the scheduled run executed in (source of the reply). */
runSessionId?: string;
scheduleId: string;
/** Normalized execution status ("success" | "failed" | ...). */
status: string;
errorMessage?: string;
logger?: { log?: (message: string, meta?: unknown) => void };
}
/**
* Deliver a completed scheduled run's result into its origin session as a
* queued follow-up turn. Returns true when the turn was queued, false when the
* origin session was not active (skipped) or delivery otherwise failed.
*/
export async function deliverScheduleResultToOriginSession(
input: OriginSessionDeliveryInput,
): Promise<boolean> {
const { host, originSessionId, runSessionId } = input;
if (!originSessionId) {
return false;
}
let body: string;
if (input.status === "success" && runSessionId) {
const messages = (await host
.readSessionMessages(runSessionId)
.catch(() => [])) as readonly LooseMessage[];
const text = extractLastAssistantText(messages);
body = text
? `[Scheduled task ${input.scheduleId} completed]\n\n${text}`
: `[Scheduled task ${input.scheduleId} completed with no textual output.]`;
} else {
body = `[Scheduled task ${input.scheduleId} ${input.status}]${
input.errorMessage ? `: ${input.errorMessage}` : "."
}`;
}
try {
await host.runTurn({
sessionId: originSessionId,
prompt: body,
delivery: "queue",
});
return true;
} catch (error) {
input.logger?.log?.(
`schedule origin-session delivery skipped (session not active): ${originSessionId}`,
{ error: error instanceof Error ? error.message : String(error) },
);
return false;
}
}
@@ -415,6 +415,14 @@ export class CronRunner {
: run.status === "queued"
? "pending"
: "failed";
const deliveryMode =
typeof spec.metadata?.deliveryMode === "string"
? spec.metadata.deliveryMode
: undefined;
const originSessionId =
typeof spec.metadata?.originSessionId === "string"
? spec.metadata.originSessionId
: undefined;
this.options.eventPublisher(eventType, {
scheduleId: spec.externalId,
executionId: run.runId,
@@ -426,6 +434,10 @@ export class CronRunner {
: undefined,
status,
errorMessage: run.error,
// Delivery routing hints for agent-created schedules. Consumers use
// these to feed a run's output back into the origin session.
...(deliveryMode ? { deliveryMode } : {}),
...(originSessionId ? { originSessionId } : {}),
});
}
@@ -526,7 +538,12 @@ export class CronRunner {
provider,
model,
mode,
source: spec.source?.trim() || "user",
// Hub-created schedules surface their run sessions in history tagged
// with source "schedule" so users can distinguish scheduled runs.
source:
spec.source?.trim() === "hub-schedule"
? "schedule"
: spec.source?.trim() || "user",
systemPrompt: await this.buildSystemPrompt(
spec,
workspaceRoot,
@@ -60,6 +60,10 @@ export class HubScheduleCommandService {
tags: Array.isArray(envelope.payload?.tags)
? (envelope.payload?.tags as string[])
: undefined,
originSessionId:
typeof envelope.payload?.originSessionId === "string"
? envelope.payload.originSessionId
: undefined,
}),
});
case "schedule.get":
@@ -83,6 +83,12 @@ export interface ListSchedulesOptions {
enabled?: boolean;
limit?: number;
tags?: string[];
/**
* Only return schedules created by an agent from this origin session
* (matched against `metadata.originSessionId`). Used to surface the
* schedule↔session linkage ("N scheduled tasks opened for this session").
*/
originSessionId?: string;
}
export interface ListScheduleExecutionsOptions {
@@ -249,9 +255,22 @@ export class HubScheduleService {
}
public listSchedules(options: ListSchedulesOptions = {}): ScheduleRecord[] {
return this.store
.listHubSchedules(options)
const { originSessionId, limit } = options;
// When filtering by origin session we must filter on the mapped record's
// metadata, so drop the store-level limit and re-apply it afterwards.
const storeOptions = originSessionId
? { ...options, limit: undefined }
: options;
const schedules = this.store
.listHubSchedules(storeOptions)
.map((spec) => specToSchedule(spec));
if (!originSessionId) {
return schedules;
}
const filtered = schedules.filter(
(schedule) => schedule.metadata?.originSessionId === originSessionId,
);
return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
}
public updateSchedule(
@@ -19,6 +19,7 @@ export const DefaultToolNames = {
SKILLS: "skills",
ASK: "ask_question",
SUBMIT_AND_EXIT: "submit_and_exit",
SCHEDULE_TASK: "schedule_task",
} as const;
/**
@@ -34,4 +35,5 @@ export const ALL_DEFAULT_TOOL_NAMES: DefaultToolName[] = [
DefaultToolNames.SKILLS,
DefaultToolNames.ASK,
DefaultToolNames.SUBMIT_AND_EXIT,
DefaultToolNames.SCHEDULE_TASK,
];
@@ -46,6 +46,8 @@ import {
ReadFilesInputSchema,
ReadFilesInputUnionSchema,
RunCommandsInputSchema,
type ScheduleTaskInput,
ScheduleTaskInputSchema,
type SearchCodebaseInput,
SearchCodebaseInputSchema,
SearchCodebaseUnionInputSchema,
@@ -62,6 +64,7 @@ import type {
DefaultToolsConfig,
EditorExecutor,
FileReadExecutor,
ScheduleTaskExecutor,
SearchExecutor,
ShellExecutor,
SkillsExecutorWithMetadata,
@@ -445,6 +448,40 @@ export function createShellTool(
});
}
/**
* Create the schedule_task tool
*
* Lets the agent create a recurring scheduled task. The actual scheduling work
* is delegated to the injected {@link ScheduleTaskExecutor}, which closes over a
* host-provided schedule client — that is why this tool only appears when a
* `scheduleTask` executor is supplied.
*/
export function createScheduleTaskTool(
executor: ScheduleTaskExecutor,
config: Pick<DefaultToolsConfig, "scheduleTaskTimeoutMs"> = {},
): AgentTool<ScheduleTaskInput, string> {
const timeoutMs = config.scheduleTaskTimeoutMs ?? 15000;
return createTool<ScheduleTaskInput, string>({
name: "schedule_task",
description:
"Schedule a recurring task that runs a prompt on a cron cadence. " +
"Use `schedule` for a five-field cron pattern (e.g. '0 9 * * *' for 09:00 daily). " +
"`deliverTo` controls where each run's output goes: 'new_session' (an independent session that shows up in session history), " +
"'origin_session' (delivered back into THIS session as follow-up work for you to continue), or " +
"'connector' (posted into the current chat thread as a notification; only valid inside a connector session). " +
"Defaults to 'new_session'. Use this when the user asks to run something on a schedule or be reminded periodically.",
inputSchema: zodToJsonSchema(ScheduleTaskInputSchema),
timeoutMs,
retryable: false,
maxRetries: 0,
execute: async (input, context) => {
const validatedInput = validateWithZod(ScheduleTaskInputSchema, input);
return executor(validatedInput, context);
},
});
}
/**
* Create the fetch_web_content tool
*
@@ -821,6 +858,7 @@ export function createDefaultTools(
enableSkills = true,
enableAskQuestion = true,
enableSubmitAndExit = false,
enableScheduleTask = true,
...config
} = options;
@@ -872,5 +910,10 @@ export function createDefaultTools(
tools.push(createSubmitAndExitTool(submitExecutor, config));
}
// Add schedule_task tool if enabled and executor provided
if (enableScheduleTask && executors.scheduleTask) {
tools.push(createScheduleTaskTool(executors.scheduleTask, config));
}
return tools as unknown as AgentTool[];
}
@@ -41,6 +41,14 @@ export {
createFileReadExecutor,
type FileReadExecutorOptions,
} from "./file-read";
export {
createScheduleTaskExecutor,
type ScheduleTaskClient,
type ScheduleTaskConnectorDelivery,
type ScheduleTaskCreateInput,
type ScheduleTaskCreateResult,
type ScheduleTaskExecutorOptions,
} from "./schedule-task";
export { createSearchExecutor, type SearchExecutorOptions } from "./search";
export {
createWebFetchExecutor,
@@ -0,0 +1,131 @@
import type { AgentToolContext } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import type { ScheduleTaskInput } from "../schemas";
import {
createScheduleTaskExecutor,
type ScheduleTaskCreateInput,
} from "./schedule-task";
function ctx(sessionId?: string): AgentToolContext {
return { agentId: "agent-1", iteration: 0, sessionId };
}
function baseInput(
overrides: Partial<ScheduleTaskInput> = {},
): ScheduleTaskInput {
return {
name: "Daily summary",
prompt: "Summarize activity",
schedule: "0 9 * * *",
...overrides,
};
}
describe("createScheduleTaskExecutor", () => {
it("defaults deliverTo to new_session and records originSessionId in metadata", async () => {
const createSchedule = vi.fn(async (_input: ScheduleTaskCreateInput) => ({
scheduleId: "sched_1",
nextRunAt: 1_000,
}));
const executor = createScheduleTaskExecutor({
client: { createSchedule },
defaults: { workspaceRoot: "/repo", cwd: "/repo/app" },
});
const result = await executor(baseInput(), ctx("origin-1"));
expect(createSchedule).toHaveBeenCalledTimes(1);
const call = createSchedule.mock.calls[0][0];
expect(call.name).toBe("Daily summary");
expect(call.cronPattern).toBe("0 9 * * *");
expect(call.workspaceRoot).toBe("/repo");
expect(call.cwd).toBe("/repo/app");
expect(call.createdBy).toBe("agent");
expect(call.originSessionId).toBe("origin-1");
expect(call.metadata).toMatchObject({
deliveryMode: "new_session",
originSessionId: "origin-1",
});
expect(result).toContain("sched_1");
expect(result).toContain("new_session");
});
it("passes deliveryMode=origin_session through metadata", async () => {
const createSchedule = vi.fn(async () => ({ scheduleId: "sched_2" }));
const executor = createScheduleTaskExecutor({
client: { createSchedule },
defaults: { workspaceRoot: "/repo" },
});
await executor(baseInput({ deliverTo: "origin_session" }), ctx("origin-2"));
const call = createSchedule.mock.calls[0][0];
expect(call.metadata?.deliveryMode).toBe("origin_session");
expect(call.metadata?.originSessionId).toBe("origin-2");
});
it("attaches the connector delivery descriptor when provided", async () => {
const createSchedule = vi.fn(async () => ({ scheduleId: "sched_3" }));
const executor = createScheduleTaskExecutor({
client: { createSchedule },
defaults: { workspaceRoot: "/repo" },
connectorDelivery: { adapter: "telegram", threadId: "telegram:42" },
});
await executor(baseInput({ deliverTo: "connector" }), ctx("origin-3"));
const call = createSchedule.mock.calls[0][0];
expect(call.metadata?.deliveryMode).toBe("connector");
expect(call.metadata?.delivery).toEqual({
adapter: "telegram",
threadId: "telegram:42",
});
});
it("leaves delivery unset for connector mode when no descriptor is provided (host resolves it)", async () => {
const createSchedule = vi.fn(async () => ({ scheduleId: "sched_4" }));
const executor = createScheduleTaskExecutor({
client: { createSchedule },
defaults: { workspaceRoot: "/repo" },
});
await executor(baseInput({ deliverTo: "connector" }), ctx("origin-4"));
const call = createSchedule.mock.calls[0][0];
expect(call.metadata?.deliveryMode).toBe("connector");
expect(call.metadata?.delivery).toBeUndefined();
});
it("prefers explicit workspaceRoot/cwd from the tool input over defaults", async () => {
const createSchedule = vi.fn(async () => ({ scheduleId: "sched_5" }));
const executor = createScheduleTaskExecutor({
client: { createSchedule },
defaults: { workspaceRoot: "/repo", cwd: "/repo" },
});
await executor(
baseInput({ workspaceRoot: "/other", cwd: "/other/pkg" }),
ctx("origin-5"),
);
const call = createSchedule.mock.calls[0][0];
expect(call.workspaceRoot).toBe("/other");
expect(call.cwd).toBe("/other/pkg");
});
it("records the timezone in metadata when provided", async () => {
const createSchedule = vi.fn(async () => ({ scheduleId: "sched_6" }));
const executor = createScheduleTaskExecutor({
client: { createSchedule },
defaults: { workspaceRoot: "/repo" },
});
await executor(
baseInput({ timezone: "America/New_York" }),
ctx("origin-6"),
);
const call = createSchedule.mock.calls[0][0];
expect(call.metadata?.timezone).toBe("America/New_York");
});
});
@@ -0,0 +1,143 @@
/**
* Executor implementation for the `schedule_task` agent tool.
*
* The tool lets an agent create a recurring scheduled task from inside a
* session. This module is host-agnostic: it defines a minimal
* {@link ScheduleTaskClient} seam that the host wires to a real schedule
* service (e.g. the hub `HubScheduleService`, or a `LocalScheduleClient`), and
* a factory that builds the executor closure around it.
*/
import type { ScheduleTaskExecutor } from "../types";
/**
* Delivery descriptor for `deliverTo: "connector"` — mirrors the `delivery`
* block the connector host stashes in a schedule's metadata for user-typed
* `/schedule create`, so the existing per-adapter delivery path can post the
* result into the originating chat thread with no extra wiring.
*/
export interface ScheduleTaskConnectorDelivery {
adapter: string;
threadId?: string;
bindingKey?: string;
channelId?: string;
participantKey?: string;
userName?: string;
}
/**
* Normalized input the executor passes to the host's schedule client.
*
* `workspaceRoot` is optional: when the model omits it and the host provides no
* default, the host client is expected to resolve it from the origin session
* (`metadata.originSessionId`).
*/
export interface ScheduleTaskCreateInput {
name: string;
cronPattern: string;
prompt: string;
workspaceRoot?: string;
cwd?: string;
mode?: "act" | "plan";
timezone?: string;
/** Origin session id (also mirrored in `metadata.originSessionId`). */
originSessionId?: string;
/** Who created the schedule; the tool passes "agent". */
createdBy?: string;
/**
* Free-form metadata persisted on the schedule spec. The tool populates
* `deliveryMode`, `originSessionId`, and (for connector delivery) `delivery`.
*/
metadata?: Record<string, unknown>;
}
export interface ScheduleTaskCreateResult {
scheduleId: string;
/** Epoch millis of the next scheduled run, when known. */
nextRunAt?: number;
}
/**
* Minimal schedule client the executor depends on. The host implements this by
* delegating to whatever schedule service it has access to.
*/
export interface ScheduleTaskClient {
createSchedule(
input: ScheduleTaskCreateInput,
): Promise<ScheduleTaskCreateResult>;
}
export interface ScheduleTaskExecutorOptions {
/** Client used to persist the schedule. */
client: ScheduleTaskClient;
/**
* Session defaults used when the model omits `workspaceRoot`/`cwd`.
*/
defaults?: {
workspaceRoot?: string;
cwd?: string;
};
/**
* When the current session is connector-backed, the delivery descriptor for
* its chat thread. Required for `deliverTo: "connector"`; absent otherwise.
*/
connectorDelivery?: ScheduleTaskConnectorDelivery;
}
/**
* Build the `schedule_task` executor closure. The returned executor reads the
* origin session id from the tool context and records it (plus the chosen
* delivery mode) on the schedule's metadata so downstream delivery and the
* schedule↔session linkage can find it.
*/
export function createScheduleTaskExecutor(
options: ScheduleTaskExecutorOptions,
): ScheduleTaskExecutor {
return async (input, context) => {
const deliverTo = input.deliverTo ?? "new_session";
// workspaceRoot/cwd may be omitted; the host client resolves them from the
// origin session when they are absent.
const workspaceRoot =
input.workspaceRoot?.trim() || options.defaults?.workspaceRoot?.trim();
const cwd = input.cwd?.trim() || options.defaults?.cwd?.trim();
const originSessionId = context.sessionId?.trim();
const metadata: Record<string, unknown> = {
deliveryMode: deliverTo,
...(originSessionId ? { originSessionId } : {}),
...(input.timezone ? { timezone: input.timezone } : {}),
};
// For connector delivery, use a host-provided descriptor when present
// (e.g. a client-side executor that already knows its thread). Otherwise
// leave it unset so the host client can resolve it from the origin
// session's metadata (the hub does this); it validates/errors there.
if (deliverTo === "connector" && options.connectorDelivery) {
metadata.delivery = options.connectorDelivery;
}
const result = await options.client.createSchedule({
name: input.name,
cronPattern: input.schedule,
prompt: input.prompt,
workspaceRoot,
cwd,
mode: input.mode,
timezone: input.timezone,
originSessionId,
createdBy: "agent",
metadata,
});
const parts = [
`Scheduled task "${input.name}" created (id: ${result.scheduleId}).`,
`Cron: ${input.schedule}${input.timezone ? ` in ${input.timezone}` : ""}.`,
`Delivery: ${deliverTo}.`,
];
if (typeof result.nextRunAt === "number") {
parts.push(`Next run: ${new Date(result.nextRunAt).toISOString()}.`);
}
return parts.join(" ");
};
}
@@ -15,6 +15,7 @@ export {
createDefaultTools,
createEditorTool,
createReadFilesTool,
createScheduleTaskTool,
createSearchTool,
createShellTool,
createSkillsTool,
@@ -31,6 +32,7 @@ export {
createDefaultShellExecutor,
createEditorExecutor,
createFileReadExecutor,
createScheduleTaskExecutor,
createSearchExecutor,
createShellExecutor,
createWebFetchExecutor,
@@ -39,6 +41,11 @@ export {
type FileReadExecutorOptions,
PatchActionType,
type PatchFileChange,
type ScheduleTaskClient,
type ScheduleTaskConnectorDelivery,
type ScheduleTaskCreateInput,
type ScheduleTaskCreateResult,
type ScheduleTaskExecutorOptions,
type SearchExecutorOptions,
type ShellExecutorOptions,
type WebFetchExecutorOptions,
@@ -86,6 +93,10 @@ export {
ReadFilesInputSchema,
type RunCommandsInput,
RunCommandsInputSchema,
type ScheduleTaskDeliverTo,
ScheduleTaskDeliverToSchema,
type ScheduleTaskInput,
ScheduleTaskInputSchema,
type SearchCodebaseInput,
SearchCodebaseInputSchema,
type SkillsInput,
@@ -107,6 +118,7 @@ export type {
DefaultToolsConfig,
EditorExecutor,
FileReadExecutor,
ScheduleTaskExecutor,
SearchExecutor,
ShellExecutor,
SkillsExecutor,
@@ -44,6 +44,7 @@ const TOOL_NAME_TO_FLAG: Record<
| "enableSkills"
| "enableAskQuestion"
| "enableSubmitAndExit"
| "enableScheduleTask"
>
> = {
read_files: "enableReadFiles",
@@ -55,6 +56,7 @@ const TOOL_NAME_TO_FLAG: Record<
skills: "enableSkills",
ask_question: "enableAskQuestion",
submit_and_exit: "enableSubmitAndExit",
schedule_task: "enableScheduleTask",
};
export const DEFAULT_MODEL_TOOL_ROUTING_RULES: ToolRoutingRule[] = [
@@ -346,3 +346,79 @@ export type AskQuestionInput = z.infer<typeof AskQuestionInputSchema>;
* Input for the submit and exit tool
*/
export type SubmitInput = z.infer<typeof SubmitInputSchema>;
/**
* Where a scheduled task's output is delivered.
* - `new_session`: each run creates its own session that appears in session
* history (marked with source=schedule).
* - `origin_session`: the run's output is delivered back into the session that
* created the schedule, as follow-up work for the main agent.
* - `connector`: the run's output is posted into the connector chat thread this
* session belongs to (only valid inside a connector-backed session).
*/
export const ScheduleTaskDeliverToSchema = z
.enum(["new_session", "origin_session", "connector"])
.describe(
"Where each scheduled run's output goes: 'new_session' (independent session in history), 'origin_session' (fed back into this session as follow-up work), or 'connector' (posted into the current chat thread; only valid in a connector session).",
);
/**
* Schema for the schedule_task tool input
*/
export const ScheduleTaskInputSchema = z
.object({
name: z
.string()
.min(1)
.describe("Short human-readable name for the scheduled task."),
prompt: z
.string()
.min(1)
.describe(
"The task instructions the scheduled run should execute each time it fires.",
),
schedule: z
.string()
.min(1)
.describe(
"Five-field cron pattern describing the cadence, e.g. '0 9 * * *' for 09:00 every day.",
),
deliverTo: ScheduleTaskDeliverToSchema.optional().describe(
"Where each run's output goes. Defaults to 'new_session' when omitted.",
),
timezone: z
.string()
.optional()
.describe(
"Optional IANA timezone for interpreting the cron pattern, e.g. 'America/New_York'.",
),
mode: z
.enum(["act", "plan"])
.optional()
.describe("Optional agent mode for the scheduled run."),
workspaceRoot: z
.string()
.optional()
.describe(
"Optional absolute workspace root for the run; defaults to this session's workspace.",
),
cwd: z
.string()
.optional()
.describe(
"Optional working directory for the run; defaults to this session's cwd.",
),
})
.describe(
"Create a recurring scheduled task that runs a prompt on a cron cadence.",
);
/**
* Input for the schedule_task tool
*/
export type ScheduleTaskInput = z.infer<typeof ScheduleTaskInputSchema>;
/**
* Delivery target for `deliverTo: 'new_session' | 'origin_session' | 'connector'`.
*/
export type ScheduleTaskDeliverTo = z.infer<typeof ScheduleTaskDeliverToSchema>;
@@ -13,6 +13,7 @@ import type {
ApplyPatchInput,
EditFileInput,
ReadFileRequest,
ScheduleTaskInput,
StructuredCommandInput,
} from "./schemas";
@@ -192,6 +193,23 @@ export type VerifySubmitExecutor = (
context: AgentToolContext,
) => Promise<string>;
/**
* Executor for creating a scheduled (recurring) task
*
* The executor closes over a schedule client (and, when the session is
* connector-backed, the current thread's delivery descriptor). It is only
* injected for hosts that can reach a schedule service, which is why
* `schedule_task` only appears when this executor is provided.
*
* @param input - Validated schedule_task input
* @param context - Tool execution context (carries the origin `sessionId`)
* @returns A human-readable confirmation string for the model
*/
export type ScheduleTaskExecutor = (
input: ScheduleTaskInput,
context: AgentToolContext,
) => Promise<string>;
/**
* Collection of all tool executors
*/
@@ -214,6 +232,8 @@ export interface ToolExecutors {
askQuestion?: AskQuestionExecutor;
/** Final submission implementation */
submit?: VerifySubmitExecutor;
/** Scheduled-task creation implementation */
scheduleTask?: ScheduleTaskExecutor;
}
// =============================================================================
@@ -232,7 +252,8 @@ export type DefaultToolName =
| "editor"
| "skills"
| "ask_question"
| "submit_and_exit";
| "submit_and_exit"
| "schedule_task";
/**
* Configuration for enabling/disabling default tools
@@ -292,6 +313,13 @@ export interface DefaultToolsConfig {
*/
enableSubmitAndExit?: boolean;
/**
* Enable the schedule_task tool. Only takes effect when a `scheduleTask`
* executor is also provided (it requires a schedule client the host injects).
* @default true
*/
enableScheduleTask?: boolean;
/**
* Current working directory for tools that need it
*/
@@ -344,6 +372,12 @@ export interface DefaultToolsConfig {
* @default 15000
*/
submitTimeoutMs?: number;
/**
* Timeout for schedule_task operations in milliseconds
* @default 15000
*/
scheduleTaskTimeoutMs?: number;
}
/**
@@ -3,12 +3,15 @@ import type {
HubCommandEnvelope,
HubEventEnvelope,
HubReplyEnvelope,
HubScheduleCreateInput,
ToolApprovalRequest,
} from "@cline/shared";
import { captureSdkError, createSessionId } from "@cline/shared";
import { deliverScheduleResultToOriginSession } from "../../cron/delivery/origin-session-delivery";
import { CronService } from "../../cron/service/cron-service";
import { HubScheduleCommandService } from "../../cron/service/schedule-command-service";
import { HubScheduleService } from "../../cron/service/schedule-service";
import { createScheduleTaskExecutor } from "../../extensions/tools";
import { LocalRuntimeHost } from "../../runtime/host/local-runtime-host";
import type {
PendingPromptsRuntimeService,
@@ -184,13 +187,78 @@ export class HubServerTransport implements NativeHubTransport {
private readonly ctx: HubTransportContext;
constructor(readonly options: HubWebSocketServerOptions) {
// The schedule_task tool executor runs inside hub-hosted agent sessions.
// It reaches this hub's own HubScheduleService (assigned later in this
// constructor) via a lazy reference and resolves workspaceRoot/cwd from
// the origin session when the agent omits them.
const sessionHostRef: {
current?: RuntimeHost & Partial<PendingPromptsRuntimeService>;
} = {};
const scheduleTaskExecutor = createScheduleTaskExecutor({
client: {
createSchedule: async (input) => {
let workspaceRoot = input.workspaceRoot?.trim();
let cwd = input.cwd?.trim();
const originSessionId = input.originSessionId?.trim();
const metadata: Record<string, unknown> = {
...(input.metadata ?? {}),
};
const needsConnectorDelivery =
metadata.deliveryMode === "connector" && !metadata.delivery;
if (
(!workspaceRoot || !cwd || needsConnectorDelivery) &&
originSessionId
) {
const originSession = await sessionHostRef.current
?.getSession(originSessionId)
.catch(() => undefined);
workspaceRoot = workspaceRoot || originSession?.workspaceRoot;
cwd = cwd || originSession?.cwd;
if (needsConnectorDelivery) {
const delivery = originSession?.metadata?.delivery;
if (delivery && typeof delivery === "object") {
metadata.delivery = delivery;
}
}
}
if (metadata.deliveryMode === "connector" && !metadata.delivery) {
throw new Error(
"schedule_task deliverTo='connector' is only available inside a connector-backed session (no thread delivery target found).",
);
}
if (!workspaceRoot) {
throw new Error(
"schedule_task could not resolve a workspaceRoot for the scheduled run.",
);
}
const created = this.schedules.createSchedule({
name: input.name,
cronPattern: input.cronPattern,
prompt: input.prompt,
workspaceRoot,
cwd,
mode: input.mode,
createdBy: input.createdBy,
metadata: metadata as HubScheduleCreateInput["metadata"],
});
return {
scheduleId: created.scheduleId,
nextRunAt: created.nextRunAt,
};
},
},
});
this.sessionHost =
options.sessionHost ??
new LocalRuntimeHost({
sessionService: new CoreSessionService(new SqliteSessionStore()),
fetch: options.fetch,
telemetry: options.telemetry,
capabilities: {
toolExecutors: { scheduleTask: scheduleTaskExecutor },
},
});
sessionHostRef.current = this.sessionHost;
this.ctx = {
clients: this.clients,
sessionState: this.sessionState,
@@ -239,6 +307,41 @@ export class HubServerTransport implements NativeHubTransport {
: undefined,
),
);
// For agent-created schedules with deliverTo:"origin_session",
// feed the run's result back into the originating session as a
// queued follow-up turn (best-effort; skipped if not active).
const record =
payload && typeof payload === "object"
? (payload as Record<string, unknown>)
: undefined;
if (record?.deliveryMode === "origin_session") {
const originSessionId =
typeof record.originSessionId === "string"
? record.originSessionId
: undefined;
if (originSessionId) {
void deliverScheduleResultToOriginSession({
host: this.sessionHost,
originSessionId,
runSessionId:
typeof record.sessionId === "string"
? record.sessionId
: undefined,
scheduleId:
typeof record.scheduleId === "string"
? record.scheduleId
: "unknown",
status:
typeof record.status === "string" ? record.status : "unknown",
errorMessage:
typeof record.errorMessage === "string"
? record.errorMessage
: undefined,
}).catch(() => {
// Best-effort delivery.
});
}
}
},
});
this.scheduleCommands = new HubScheduleCommandService(this.schedules);
+1
View File
@@ -45,6 +45,7 @@ export const SessionSource = {
IDE: "ide",
JETBRAINS: "jetbrains",
NEOVIM: "neovim",
SCHEDULE: "schedule",
UNKNOWN: "unknown",
} as const;