Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ app/api/
skills/install/route.ts POST install skills via npx skills add
skills/search/route.ts POST skills.sh search
subagents/settings/route.ts GET/PUT built-in subagent switch and maxConcurrent
scheduled-prompts/route.ts GET switch + active jobs | PUT { enabled } | DELETE { cwd, jobId } cancel (lib/scheduled-prompts.ts: the schedule_prompt tool, replaces pi-schedule-prompt; Settings › Scheduled prompts)
web-auth/route.ts GET status | POST login | DELETE logout (browser password)
provider-usage/query/route.ts POST provider usage quotas
push/config/route.ts GET VAPID public key
Expand Down
71 changes: 71 additions & 0 deletions app/api/scheduled-prompts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
import {
cancelScheduledJob,
listActiveScheduledJobs,
stopAllScheduledPrompts,
} from "@/lib/scheduled-prompts";
import { isScheduledPromptsEnabled, writeScheduledPromptsEnabled } from "@/lib/scheduled-prompts-settings";

export const dynamic = "force-dynamic";

function snapshot() {
const enabled = isScheduledPromptsEnabled();
return { enabled, jobs: enabled ? listActiveScheduledJobs() : [] };
}

function failure(error: unknown) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}

export async function GET() {
try {
return NextResponse.json(snapshot());
} catch (error) {
return failure(error);
}
}

async function readBody(req: Request): Promise<Record<string, unknown> | NextResponse> {
if (!isApiRequestAllowed(req)) return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
if (!hasJsonContentType(req)) return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
const body: unknown = await req.json().catch(() => null);
if (body === null || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Body must be a JSON object" }, { status: 400 });
}
return body as Record<string, unknown>;
}

export async function PUT(req: Request) {
const body = await readBody(req);
if (body instanceof NextResponse) return body;
if (typeof body.enabled !== "boolean") {
return NextResponse.json({ error: "enabled must be a boolean" }, { status: 400 });
}
try {
writeScheduledPromptsEnabled(body.enabled);
// Switching off ends every live timer now. Switching on needs a session reload to register the tool.
if (!body.enabled) stopAllScheduledPrompts();
return NextResponse.json(snapshot());
} catch (error) {
return failure(error);
}
}

export async function DELETE(req: Request) {
const body = await readBody(req);
if (body instanceof NextResponse) return body;
if (typeof body.cwd !== "string" || !body.cwd || typeof body.jobId !== "string" || !body.jobId) {
return NextResponse.json({ error: "cwd and jobId are required" }, { status: 400 });
}
// Only a job a live session is firing can be cancelled here, never an arbitrary path.
if (!listActiveScheduledJobs().some((job) => job.cwd === body.cwd && job.id === body.jobId)) {
return NextResponse.json({ error: "Scheduled prompt not found" }, { status: 404 });
}
try {
cancelScheduledJob(body.cwd, body.jobId);
return NextResponse.json(snapshot());
} catch (error) {
return failure(error);
}
}
76 changes: 73 additions & 3 deletions app/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -1468,13 +1468,12 @@
.settings-section-tab {
position: relative;
display: flex;
flex: 0 0 96px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 5px;
width: 96px;
height: 100%;
padding: 0 2px;
padding: 0 14px;
border: 0;
outline: none;
background: transparent;
Expand Down Expand Up @@ -2625,3 +2624,74 @@ select.mcp-add-input {
}
}
}

.schedule-jobs {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 14px 18px;
overflow-y: auto;
}

.schedule-jobs-title {
color: var(--text);
font-size: 12px;
}

.schedule-job-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}

.schedule-job {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-panel);
}

.schedule-job-main {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
font-size: 12px;
}

.schedule-job-heading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
color: var(--text);
}

.schedule-job-heading code {
color: var(--text-muted);
font-size: 11px;
}

.schedule-job-prompt {
overflow: hidden;
color: var(--text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}

.schedule-job-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
color: var(--text-dim);
font-size: 11px;
}
172 changes: 172 additions & 0 deletions components/ScheduledPromptsConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { useI18n } from "@/hooks/useI18n";
import type { ScheduledPromptsResponse } from "@/lib/api-types";
import { sendAgentCommand } from "@/lib/agent-client";
import { shortenPath } from "@/lib/display-path";
import { ConfigButton, ConfigEmptyState, ConfigPanelShell, ConfigSwitch } from "./SettingsUi";

interface Props {
embedded?: boolean;
sessionId?: string | null;
onClose: () => void;
onReloaded?: () => void;
}

const REFRESH_MS = 5000;

async function request(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise<ScheduledPromptsResponse> {
const response = await fetch("/api/scheduled-prompts", {
method,
...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
});
const data = await response.json() as Partial<ScheduledPromptsResponse> & { error?: string };
if (!response.ok || data.error || typeof data.enabled !== "boolean" || !Array.isArray(data.jobs)) {
throw new Error(data.error ?? `HTTP ${response.status}`);
}
return { enabled: data.enabled, jobs: data.jobs };
}

function formatTime(iso: string | undefined): string {
if (!iso) return "–";
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
}

export function ScheduledPromptsConfig({ embedded, sessionId, onClose, onReloaded }: Props) {
const { t } = useI18n();
const [data, setData] = useState<ScheduledPromptsResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [cancelling, setCancelling] = useState<string | null>(null);
const [reloadNeeded, setReloadNeeded] = useState(false);
const [reloading, setReloading] = useState(false);

const refresh = useCallback(async () => {
try {
setData(await request("GET"));
setError(null);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
}, []);

useEffect(() => {
void refresh();
const timer = setInterval(() => void refresh(), REFRESH_MS);
return () => clearInterval(timer);
}, [refresh]);

const toggle = async (enabled: boolean) => {
setSaving(true);
setError(null);
try {
setData(await request("PUT", { enabled }));
setReloadNeeded(Boolean(sessionId));
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};

const cancel = async (cwd: string, jobId: string) => {
setCancelling(`${cwd}\n${jobId}`);
setError(null);
try {
setData(await request("DELETE", { cwd, jobId }));
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
void refresh();
} finally {
setCancelling(null);
}
};

const reloadSession = async () => {
if (!sessionId) return;
setReloading(true);
try {
await sendAgentCommand(sessionId, { type: "reload" });
setReloadNeeded(false);
onReloaded?.();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setReloading(false);
}
};

const enabled = data?.enabled ?? false;

return (
<ConfigPanelShell embedded={embedded ?? false} title={t("schedule.title")} closeLabel={t("i18n.close")} onClose={onClose}>
<div className="agents-feature-setting">
<div className="agents-feature-copy">
<strong>{t("schedule.enableTitle")}</strong>
<span>{t("schedule.enableDescription")}</span>
{reloadNeeded && <span role="status" className="agents-feature-reload-notice">{t("schedule.reloadRequired")}</span>}
{error && <span role="alert" className="agents-feature-reload-notice">{error}</span>}
</div>
<div className="agents-feature-actions">
{reloadNeeded && sessionId && (
<ConfigButton size="small" onClick={() => void reloadSession()} disabled={reloading || saving}>
{reloading ? t("agents.reloading") : t("agents.reloadSession")}
</ConfigButton>
)}
<ConfigSwitch
checked={enabled}
disabled={data === null || reloading}
loading={saving}
label={t("schedule.enableTitle")}
onChange={(next) => void toggle(next)}
/>
</div>
</div>
<div className="schedule-jobs">
<strong className="schedule-jobs-title">{t("schedule.active")}</strong>
{data === null ? (
<ConfigEmptyState>{t("schedule.loading")}</ConfigEmptyState>
) : !enabled ? (
<ConfigEmptyState>{t("schedule.disabledNotice")}</ConfigEmptyState>
) : data.jobs.length === 0 ? (
<ConfigEmptyState>{t("schedule.empty")}</ConfigEmptyState>
) : (
<ul className="schedule-job-list">
{data.jobs.map((job) => {
const key = `${job.cwd}\n${job.id}`;
return (
<li key={key} className="schedule-job">
<div className="schedule-job-main">
<div className="schedule-job-heading">
<strong>{job.name}</strong>
<code>{job.scheduleText}</code>
</div>
<div className="schedule-job-prompt" title={job.prompt}>{job.prompt}</div>
<div className="schedule-job-meta">
<span title={job.cwd}>{shortenPath(job.cwd)}</span>
<span>{job.boundToSession ? t("schedule.scopeSession") : t("schedule.scopeWorkdir")}</span>
<span>{t("schedule.nextRun")}: {formatTime(job.nextRun)}</span>
<span>{t("schedule.lastRun")}: {formatTime(job.lastRun)}</span>
<span>{t("schedule.runs")}: {job.runCount}</span>
</div>
</div>
<ConfigButton
size="small"
variant="danger"
aria-label={t("schedule.cancelLabel", { name: job.name })}
disabled={cancelling === key}
onClick={() => void cancel(job.cwd, job.id)}
>
{cancelling === key ? t("schedule.cancelling") : t("schedule.cancel")}
</ConfigButton>
</li>
);
})}
</ul>
)}
</div>
</ConfigPanelShell>
);
}
4 changes: 4 additions & 0 deletions components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { ModelsConfig } from "./ModelsConfig";
import { setupPushSubscription } from "@/lib/push-client";
import { SkillsConfig } from "./SkillsConfig";
import { ScheduledPromptsConfig } from "./ScheduledPromptsConfig";
import { AgentsConfig } from "./AgentsConfig";
import { PluginsConfig } from "./PluginsConfig";
import { McpConfig } from "./McpConfig";
Expand Down Expand Up @@ -70,6 +71,7 @@ export function SettingsSectionIcon({ section, size = 16, strokeWidth = 1.8 }: {
if (section === "models") return <svg {...common}><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 15h3M1 9h3M1 15h3" /></svg>;
if (section === "skills") return <svg {...common}><path d="m12 2-10 5 10 5 10-5-10-5Z" /><path d="m2 12 10 5 10-5M2 17l10 5 10-5" /></svg>;
if (section === "agents") return <svg {...common} className="settings-section-icon is-agent"><rect x="5" y="7" width="14" height="11" rx="2" /><path d="M9 11h.01M15 11h.01M9 15h6M12 7V4M10 4h4" /></svg>;
if (section === "schedule") return <svg {...common}><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M9 2h6M12 2v3" /></svg>;
if (section === "mcp") return <svg {...common}><rect x="3" y="3" width="18" height="7" rx="2" /><rect x="3" y="14" width="18" height="7" rx="2" /><path d="M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6" /></svg>;
return <svg {...common}><path d="M9 7V2M15 7V2M6 13V8a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v5a6 6 0 0 1-12 0ZM12 19v3" /></svg>;
}
Expand Down Expand Up @@ -527,6 +529,7 @@ export function SettingsPanel({
{ id: "agents", label: t("common.agents") },
{ id: "plugins", label: t("common.plugins") },
{ id: "mcp", label: t("settings.mcp") },
{ id: "schedule", label: t("settings.schedule") },
] as const).map((item) => ({ ...item, requiresProject: settingsSectionRequiresProject(item.id) }));
const sectionRequiresProject = settingsSectionRequiresProject(section);

Expand Down Expand Up @@ -627,6 +630,7 @@ export function SettingsPanel({
{cwd && sectionHost("plugins", <PluginsConfig embedded key={cwd} cwd={cwd} sessionId={sessionId} trust={projectTrust} onClose={onClose} onReloaded={onSessionReloaded} />)}
{/* No project needed: the global mcp.json is listed alone, and a project adds its group. */}
{sectionHost("mcp", <McpConfig embedded key={cwd ?? ""} cwd={cwd} trust={projectTrust} onTrustProject={onOpenTrustDialog} onProjectTrustChanged={onProjectTrustChanged} onClose={onClose} />)}
{sectionHost("schedule", <ScheduledPromptsConfig embedded sessionId={sessionId} onClose={onClose} onReloaded={onSessionReloaded} />)}
</main>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface SubagentSettingsResponse {
maxConcurrent: number;
}

export interface ScheduledPromptsResponse {
enabled: boolean;
jobs: import("./scheduled-prompts").ActiveScheduledJob[];
}

/** Code mode's one choice (ADR 0006): Automatic writes nothing, Always on adds `+codemode` to the global defaultTools. */
export type McpCodemodePreference = "automatic" | "always";

Expand Down
Loading
Loading