Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 2 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,9 @@ ade chat create --lane lane-id --provider codex --no-parent # spawned chats de
ade chat read session-id --limit 20 --text
ade chat message session-id --kind auto --text "status/context"
ade chat steer session-id --text "active-turn context"
ade chat schedules session-id --pause # pause this chat's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm)
ade chat schedules session-id --pause # pause this agent session's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm)
ade chat scheduled-work list [session-id] --all # list durable jobs; --all includes recent terminal history
ade chat scheduled-work create --cron "9,29,49 * * * *" --prompt "Check CI and report" --once --reason "CI check" --session session-id # chats and tracked provider CLIs; omit --session inside the bound agent
ade chat scheduled-work cancel session-id job-id # cancel one job; Claude-native jobs request CronDelete in the owning chat
ade chat wait session-id --for idle --timeout-ms 600000
ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume
Expand Down
116 changes: 116 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,21 @@ function createRuntime() {
lastActivityAt: "2026-03-17T19:00:00.000Z",
createdAt: "2026-03-17T19:00:00.000Z",
})),
createScheduledWork: vi.fn(async ({ sessionId, cron, prompt, recurring = true }: {
sessionId: string;
cron: string;
prompt: string;
recurring?: boolean;
}) => ({
item: {
id: `action:${sessionId}:schedule-1`,
sessionId,
cron,
prompt,
kind: recurring ? "cron" : "wakeup",
status: "scheduled",
},
})),
listScheduledWork: vi.fn(async ({ sessionId, includeTerminal }: {
sessionId?: string;
includeTerminal?: boolean;
Expand All @@ -498,6 +513,12 @@ function createRuntime() {
kind: "cron",
status: includeTerminal ? "cancelled" : "scheduled",
}]),
getScheduledWorkState: vi.fn(async ({ sessionId }: { sessionId: string }) => ({
sessionId,
paused: false,
nextWakeAt: "2026-03-17T20:00:00.000Z",
items: [],
})),
cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: {
sessionId: string;
scheduleId: string;
Expand All @@ -506,6 +527,10 @@ function createRuntime() {
providerCancellationRequested: false,
providerCancellationConfirmed: true,
})),
setScheduledWorkPaused: vi.fn(async ({ sessionId, paused }: {
sessionId: string;
paused: boolean;
}) => ({ sessionId, paused, nextWakeAt: null })),
getChatTranscript: vi.fn(async ({ sessionId }: { sessionId: string }) => ({
sessionId,
entries: [{ role: "assistant", text: "hello", timestamp: "2026-03-17T19:00:00.000Z" }],
Expand Down Expand Up @@ -2811,6 +2836,16 @@ describe("adeRpcServer", () => {
expect(allScheduledWork?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({});

const scheduledWorkState = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getScheduledWorkState",
args: { sessionId: " chat-1 " },
});
expect(scheduledWorkState?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledWith({
sessionId: "chat-1",
});

const cancelledWork = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
Expand Down Expand Up @@ -3194,6 +3229,63 @@ describe("adeRpcServer", () => {
text: "own-chat write",
});

const ownScheduledWorkCreate = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "createScheduledWork",
args: { cron: "0 * * * *", prompt: "Check CI." },
});
expect(ownScheduledWorkCreate?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.createScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
cron: "0 * * * *",
prompt: "Check CI.",
});

const deniedScheduledWorkCreate = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "createScheduledWork",
args: { sessionId: "chat-2", cron: "0 * * * *", prompt: "Cross-chat schedule." },
});
expect(deniedScheduledWorkCreate.isError).toBe(true);
expect(fixture.runtime.agentChatService.createScheduledWork).toHaveBeenCalledTimes(1);

const ownScheduledWorkState = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getScheduledWorkState",
args: {},
});
expect(ownScheduledWorkState?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledWith({
sessionId: "chat-1",
});

const deniedScheduledWorkState = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getScheduledWorkState",
args: { sessionId: "chat-2" },
});
expect(deniedScheduledWorkState.isError).toBe(true);
expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledTimes(1);

const ownScheduledWorkPause = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "setScheduledWorkPaused",
args: { paused: true },
});
expect(ownScheduledWorkPause?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.setScheduledWorkPaused).toHaveBeenCalledWith({
sessionId: "chat-1",
paused: true,
});

const deniedScheduledWorkPause = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "setScheduledWorkPaused",
args: { sessionId: "chat-2", paused: true },
});
expect(deniedScheduledWorkPause.isError).toBe(true);
expect(fixture.runtime.agentChatService.setScheduledWorkPaused).toHaveBeenCalledTimes(1);

const deniedScheduledWorkList = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
Expand Down Expand Up @@ -3825,6 +3917,30 @@ describe("adeRpcServer", () => {
expect(scheduledWorkList.isError).toBe(true);
expect(fixture.runtime.agentChatService.listScheduledWork).not.toHaveBeenCalled();

const scheduledWorkCreate = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "createScheduledWork",
args: { sessionId: "chat-2", cron: "0 * * * *", prompt: "Check CI." },
});
expect(scheduledWorkCreate.isError).toBe(true);
expect(fixture.runtime.agentChatService.createScheduledWork).not.toHaveBeenCalled();

const scheduledWorkState = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getScheduledWorkState",
args: { sessionId: "chat-2" },
});
expect(scheduledWorkState.isError).toBe(true);
expect(fixture.runtime.agentChatService.getScheduledWorkState).not.toHaveBeenCalled();

const scheduledWorkPause = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "setScheduledWorkPaused",
args: { sessionId: "chat-2", paused: true },
});
expect(scheduledWorkPause.isError).toBe(true);
expect(fixture.runtime.agentChatService.setScheduledWorkPaused).not.toHaveBeenCalled();

const scheduledWorkCancel = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
Expand Down
24 changes: 12 additions & 12 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2382,18 +2382,23 @@ function scopeTerminalAdeActionArgs(
}
}

const SCOPED_CHAT_ACTIONS = new Set([
"readTranscript",
"sendMessage",
"createScheduledWork",
"listScheduledWork",
"getScheduledWorkState",
"cancelScheduledWork",
"setScheduledWorkPaused",
]);

function scopeChatAdeActionArgs(
session: SessionState,
action: string,
chatArgs: Record<string, unknown>,
): Record<string, unknown> {
const method = `run_ade_action:chat.${action}`;
if (
action !== "readTranscript"
&& action !== "sendMessage"
&& action !== "listScheduledWork"
&& action !== "cancelScheduledWork"
) return chatArgs;
if (!SCOPED_CHAT_ACTIONS.has(action)) return chatArgs;
if (isUnboundAdeCliCaller(session)) return chatArgs;

const scopedArgs = { ...chatArgs };
Expand Down Expand Up @@ -3481,12 +3486,7 @@ async function runTool(args: {
} else if (
!callerIsCto
&& domain === "chat"
&& (
action === "readTranscript"
|| action === "sendMessage"
|| action === "listScheduledWork"
|| action === "cancelScheduledWork"
)
&& SCOPED_CHAT_ACTIONS.has(action)
) {
scopedObjectArgs = scopeChatAdeActionArgs(
session,
Expand Down
67 changes: 65 additions & 2 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4169,8 +4169,8 @@ describe("ADE CLI", () => {
expect(inspect.steps[0]?.params).toMatchObject({
arguments: {
domain: "chat",
action: "getSessionSummary",
argsList: ["chat-1"],
action: "getScheduledWorkState",
args: { sessionId: "chat-1" },
},
});

Expand Down Expand Up @@ -4198,6 +4198,69 @@ describe("ADE CLI", () => {
},
});

const create = expectExecutePlan(buildCliPlan([
"chat",
"scheduled-work",
"create",
"--cron",
"9,29,49 * * * *",
"--prompt",
"Check CI and report",
]));
expect(create.label).toBe("chat scheduled-work create");
expect(create.steps[0]?.params).toMatchObject({
arguments: {
domain: "chat",
action: "createScheduledWork",
args: {
cron: "9,29,49 * * * *",
prompt: "Check CI and report",
recurring: true,
},
},
});

const createOnce = expectExecutePlan(buildCliPlan([
"chat",
"scheduled-work",
"create",
"--cron",
"15 10 * * 1",
"--prompt",
"Prepare the weekly report",
"--once",
"--reason",
"Weekly report",
"--session",
"chat-1",
]));
expect(createOnce.steps[0]?.params).toMatchObject({
arguments: {
action: "createScheduledWork",
args: {
sessionId: "chat-1",
cron: "15 10 * * 1",
prompt: "Prepare the weekly report",
recurring: false,
reason: "Weekly report",
},
},
});
expect(() => buildCliPlan([
"chat",
"scheduled-work",
"create",
"--prompt",
"Missing cron",
])).toThrow(/cron is required/);
expect(() => buildCliPlan([
"chat",
"scheduled-work",
"create",
"--cron",
"0 * * * *",
])).toThrow(/prompt is required/);

for (const alias of ["schedule", "schedules"]) {
expect(expectExecutePlan(buildCliPlan(["chat", alias, "list", "chat-1"])).steps[0]?.params)
.toMatchObject({
Expand Down
42 changes: 33 additions & 9 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1641,9 +1641,11 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat rewind-files <session> --message <user-message-id> --dry-run
Preview or apply file/context rewind
$ ade chat subagents <session> --text List child agents for a chat
$ ade chat schedules <session> --pause Pause this chat's durable wakeups/cron/loops
$ ade chat schedules <session> --pause Pause this agent session's durable wakeups/cron/loops
$ ade chat schedules <session> Inspect pause state + next armed wake (--resume to re-arm)
$ ade chat scheduled-work list [session] List durable jobs (--all includes recent history)
$ ade chat scheduled-work create --cron "<expr>" --prompt "<text>" [--once]
Optional: --reason "<text>" --session <id>
$ ade chat scheduled-work cancel <session> <id> Cancel one job; Claude crons also request CronDelete
$ ade new chat --mode cli --lane <lane> --provider claude --reasoning-effort ultracode --prompt "fix"
Start a tracked provider CLI session
Expand Down Expand Up @@ -6633,7 +6635,7 @@ function buildChatPlan(args: string[]): CliPlan {
|| sub === "schedules"
|| sub === "schedule"
)
&& (args[0] === "list" || args[0] === "cancel")
&& (args[0] === "list" || args[0] === "create" || args[0] === "cancel")
? firstStandalonePositional(args)
: null;
const sessionId =
Expand Down Expand Up @@ -7280,6 +7282,29 @@ function buildChatPlan(args: string[]): CliPlan {
sub === "schedule" ||
sub === "scheduled-work"
) {
if (scheduledWorkOperation === "create") {
const cron = requireValue(readValue(args, ["--cron"]), "cron");
const prompt = requireValue(readValue(args, ["--prompt", "--text"]), "prompt");
const reason = readValue(args, ["--reason"]);
return {
kind: "execute",
label: "chat scheduled-work create",
steps: [
actionStep(
"result",
"chat",
"createScheduledWork",
collectGenericObjectArgs(args, {
...(sessionId ? { sessionId } : {}),
cron,
prompt,
recurring: !readFlag(args, ["--once"]),
...(reason ? { reason } : {}),
}),
),
],
};
}
if (scheduledWorkOperation === "list") {
return {
kind: "execute",
Expand Down Expand Up @@ -7316,24 +7341,22 @@ function buildChatPlan(args: string[]): CliPlan {
],
};
}
// Per-chat scheduled-work control: pause/resume this chat's durable
// wakeups/cron/loop schedules, or inspect (no flag) the current pause
// state + next armed wake via getSessionSummary.
// Per-session scheduled-work control. The state query works for both chat
// sessions and tracked provider CLI terminals.
const targetSession = requireValue(sessionId, "sessionId");
const pause = readFlag(args, ["--pause", "--pause-scheduled-work"]);
const resume = readFlag(args, ["--resume", "--unpause"]);
if (pause && resume) {
throw new CliUsageError("Use either --pause or --resume, not both.");
}
if (!pause && !resume) {
// Inspection mode: summary carries nextWakeAt + scheduledWorkPaused.
return {
kind: "execute",
label: "chat schedules",
steps: [
actionArgsListStep("result", "chat", "getSessionSummary", [
targetSession,
]),
actionStep("result", "chat", "getScheduledWorkState", {
sessionId: targetSession,
}),
],
};
}
Expand Down Expand Up @@ -11242,6 +11265,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet<string> = new Set([
"--compare-to",
"--content",
"--context-file",
"--cron",
"--cwd",
"--data",
"--cpu",
Expand Down
Loading