diff --git a/packages/ui/src/components/session/ScheduledTasksDialog.tsx b/packages/ui/src/components/session/ScheduledTasksDialog.tsx
index c75e677e..6590e67b 100644
--- a/packages/ui/src/components/session/ScheduledTasksDialog.tsx
+++ b/packages/ui/src/components/session/ScheduledTasksDialog.tsx
@@ -463,6 +463,14 @@ export function ScheduledTasksDialog() {
{formatSchedule(task, t)}
+ {task.loopFile ? (
+
+ {t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
+
+ ) : null}
@@ -525,8 +533,11 @@ export function ScheduledTasksDialog() {
className={cn(
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
task.enabled ? 'text-foreground' : 'text-muted-foreground',
- isBusy && 'cursor-not-allowed opacity-50',
+ (isBusy || task.loopFile) && 'cursor-not-allowed opacity-50',
)}
+ title={task.loopFile
+ ? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled')
+ : undefined}
>
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
@@ -555,7 +566,10 @@ export function ScheduledTasksDialog() {
setEditorTask(task);
setEditorOpen(true);
}}
- disabled={isBusy}
+ disabled={isBusy || Boolean(task.loopFile)}
+ title={task.loopFile
+ ? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
+ : undefined}
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
>
{t('sessions.scheduledTasks.dialog.actions.edit')}
@@ -564,7 +578,10 @@ export function ScheduledTasksDialog() {
variant="destructive"
size="sm"
onClick={() => void handleDeleteTask(task)}
- disabled={isBusy}
+ disabled={isBusy || Boolean(task.loopFile)}
+ title={task.loopFile
+ ? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
+ : undefined}
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
>
diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts
index 57a22de2..0190f560 100644
--- a/packages/ui/src/lib/i18n/messages/de.ts
+++ b/packages/ui/src/lib/i18n/messages/de.ts
@@ -248,6 +248,9 @@ export const dict = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} pausieren',
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Aktiviert',
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Pausiert',
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'Von Loop-Datei verwaltet {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Aktiviert wird durch die Loop-Datei gesteuert; setze enabled im Markdown-Frontmatter',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop-Aufgaben werden in ihrer .agents/loops-Markdown-Datei konfiguriert',
'sessions.scheduledTasks.editor.title.edit': 'Geplante Aufgabe bearbeiten',
'sessions.scheduledTasks.editor.title.new': 'Neue geplante Aufgabe',
'sessions.scheduledTasks.editor.description': 'Konfigurieren Sie eine serverseitige Aufgabe, die eine neue Sitzung erstellt und eine Eingabeaufforderung sendet.',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index c77d6a07..5d0742a0 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -268,6 +268,9 @@ export const dict = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}',
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Enabled',
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Paused',
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'Managed by loop file {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Enabled is controlled by the loop file; set enabled in the markdown frontmatter',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop tasks are configured in their .agents/loops markdown file',
'sessions.scheduledTasks.editor.title.edit': 'Edit scheduled task',
'sessions.scheduledTasks.editor.title.new': 'New scheduled task',
'sessions.scheduledTasks.editor.description': 'Configure a server-side task that creates a new session and sends a prompt.',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 72985914..9d97a182 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -269,6 +269,9 @@ export const dict: Record = {
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}",
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Habilitado",
"sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado",
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'Gestionada por el archivo de bucle {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'La activación la controla el archivo de bucle; establece enabled en el frontmatter de Markdown',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Las tareas de bucle se configuran en su archivo Markdown .agents/loops',
"sessions.scheduledTasks.editor.title.edit": "Editar tarea programada",
"sessions.scheduledTasks.editor.title.new": "Nueva tarea programada",
"sessions.scheduledTasks.editor.description": "Configura una tarea del lado del servidor que crea una nueva sesión y envía un prompt.",
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 986a4cd4..5d40c830 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -105,6 +105,9 @@ export const dict = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}',
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Activé',
'sessions.scheduledTasks.dialog.taskToggle.paused': 'En pause',
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'Gérée par le fichier de boucle {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': "L'activation est contrôlée par le fichier de boucle ; définissez enabled dans le frontmatter Markdown",
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Les tâches de boucle sont configurées dans leur fichier Markdown .agents/loops',
'sessions.scheduledTasks.editor.title.edit': 'Modifier une tâche planifiée',
'sessions.scheduledTasks.editor.title.new': 'Nouvelle tâche planifiée',
'sessions.scheduledTasks.editor.description': 'Configurez une tâche côté serveur qui crée une nouvelle session et envoie un prompt.',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index 0f4da4e7..352c6727 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -269,6 +269,9 @@ export const dict: Record = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName}を一時停止',
'sessions.scheduledTasks.dialog.taskToggle.enabled': '有効',
'sessions.scheduledTasks.dialog.taskToggle.paused': '一時停止中',
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'ループファイル {file} によって管理',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '有効状態はループファイルが制御します。Markdown フロントマターで enabled を設定してください',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'ループタスクは .agents/loops の Markdown ファイルで設定します',
'sessions.scheduledTasks.editor.title.edit': 'スケジュールタスクを編集',
'sessions.scheduledTasks.editor.title.new': '新しいスケジュールタスク',
'sessions.scheduledTasks.editor.description': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index 0f09fba2..5ce56da8 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -269,6 +269,9 @@ export const dict: Record = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} 일시 중지',
'sessions.scheduledTasks.dialog.taskToggle.enabled': '활성화됨',
'sessions.scheduledTasks.dialog.taskToggle.paused': '일시 중지됨',
+ 'sessions.scheduledTasks.dialog.loopFile.note': '루프 파일에서 관리됨: {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '활성화 여부는 루프 파일이 제어합니다. Markdown frontmatter에서 enabled를 설정하세요',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '루프 작업은 .agents/loops Markdown 파일에서 구성합니다',
'sessions.scheduledTasks.editor.title.edit': '예약 작업 편집',
'sessions.scheduledTasks.editor.title.new': '새 예약 작업',
'sessions.scheduledTasks.editor.description': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index 12f727b1..06c5aa7f 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -396,6 +396,9 @@ export const dict: Record = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Wstrzymaj {taskName}',
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Włączone',
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Wstrzymane',
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'Zarządzane przez plik pętli {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Włączenie jest kontrolowane przez plik pętli; ustaw enabled w frontmatterze Markdown',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Zadania pętli są konfigurowane w pliku Markdown .agents/loops',
'sessions.scheduledTasks.editor.title.edit': 'Edytuj zaplanowane zadanie',
'sessions.scheduledTasks.editor.title.new': 'Nowe zaplanowane zadanie',
'sessions.scheduledTasks.editor.description': 'Skonfiguruj zadanie po stronie serwera, które tworzy nową sesję i wysyła prompt.',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 10dd7709..b33b7976 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -269,6 +269,9 @@ export const dict: Record = {
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}",
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Ativado",
"sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado",
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'Gerenciada pelo arquivo de loop {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'A ativação é controlada pelo arquivo de loop; defina enabled no frontmatter Markdown',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Tarefas de loop são configuradas no arquivo Markdown .agents/loops',
"sessions.scheduledTasks.editor.title.edit": "Editar tarefa agendada",
"sessions.scheduledTasks.editor.title.new": "Nova tarefa agendada",
"sessions.scheduledTasks.editor.description": "Configure uma tarefa do lado do servidor que cria uma nova sessão e envia um prompt.",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index dd52bcb9..4b389c8d 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -269,6 +269,9 @@ export const dict: Record = {
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Призупинити {taskName}",
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Увімкнено",
"sessions.scheduledTasks.dialog.taskToggle.paused": "Призупинено",
+ 'sessions.scheduledTasks.dialog.loopFile.note': 'Керується файлом циклу {file}',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Активність контролюється файлом циклу; встановіть enabled у frontmatter Markdown',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Завдання циклів налаштовуються у файлі Markdown .agents/loops',
"sessions.scheduledTasks.editor.title.edit": "Редагувати заплановане завдання",
"sessions.scheduledTasks.editor.title.new": "Нове заплановане завдання",
"sessions.scheduledTasks.editor.description": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 35de2c9c..057e6637 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -269,6 +269,9 @@ export const dict: Record = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暂停 {taskName}',
'sessions.scheduledTasks.dialog.taskToggle.enabled': '已启用',
'sessions.scheduledTasks.dialog.taskToggle.paused': '已暂停',
+ 'sessions.scheduledTasks.dialog.loopFile.note': '由循环文件 {file} 管理',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '启用状态由循环文件控制;请在 Markdown frontmatter 中设置 enabled',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '循环任务在其 .agents/loops Markdown 文件中配置',
'sessions.scheduledTasks.editor.title.edit': '编辑计划任务',
'sessions.scheduledTasks.editor.title.new': '新建计划任务',
'sessions.scheduledTasks.editor.description': '配置一个服务端任务,用于创建新会话并发送提示词。',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index 6641606b..7c98d160 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -282,6 +282,9 @@ export const dict: Record = {
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暫停 {taskName}',
'sessions.scheduledTasks.dialog.taskToggle.enabled': '已啟用',
'sessions.scheduledTasks.dialog.taskToggle.paused': '已暫停',
+ 'sessions.scheduledTasks.dialog.loopFile.note': '由迴圈檔案 {file} 管理',
+ 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '啟用狀態由迴圈檔案控制;請在 Markdown frontmatter 中設定 enabled',
+ 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '迴圈任務在其 .agents/loops Markdown 檔案中設定',
'sessions.scheduledTasks.editor.title.edit': '編輯排程任務',
'sessions.scheduledTasks.editor.title.new': '新增排程任務',
'sessions.scheduledTasks.editor.description': '設定一個伺服器端任務,用於建立新會話並傳送提示詞。',
diff --git a/packages/ui/src/lib/scheduledTasksApi.ts b/packages/ui/src/lib/scheduledTasksApi.ts
index 8b215d64..c7c4aeb1 100644
--- a/packages/ui/src/lib/scheduledTasksApi.ts
+++ b/packages/ui/src/lib/scheduledTasksApi.ts
@@ -6,6 +6,9 @@ export type ScheduledTask = {
id: string;
name: string;
enabled: boolean;
+ /** Absolute path of the `.agents/loops/*.md` file driving this task, when
+ * any. Present only for loop-sourced tasks; unknown to older clients. */
+ loopFile?: string;
schedule: {
kind: 'daily' | 'weekly' | 'once' | 'cron';
times?: string[];
diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js
index 54166695..ab92383e 100644
--- a/packages/web/server/lib/projects/project-config.js
+++ b/packages/web/server/lib/projects/project-config.js
@@ -2,7 +2,7 @@ import { DateTime, IANAZone } from 'luxon';
import parser from 'cron-parser';
const PROJECT_CONFIG_VERSION = 1;
-const MAX_TASK_NAME_LENGTH = 80;
+export const MAX_TASK_NAME_LENGTH = 80;
const MAX_TASK_PROMPT_LENGTH = 20_000;
const MAX_CRON_LENGTH = 200;
const MAX_LAST_ERROR_LENGTH = 2_000;
diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md
index 5bdba849..4de46ab5 100644
--- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md
+++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md
@@ -48,7 +48,7 @@ Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`):
| Frontmatter | Task field |
|---|---|
-| `name` | `name` (required) |
+| `name` | `name` (required, max 80 characters — longer names are rejected as malformed) |
| `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) |
| `enabled` | `enabled` (default `false` — a loop only runs when the file explicitly enables it; add `enabled: true` to activate) |
| `model` | split on the first `/` into `execution.providerID` / `execution.modelID` (required) |
@@ -97,7 +97,9 @@ project write lock on every `syncProject` when the project path is known:
file remains authoritative: the next reconciliation re-applies the file's
definition (including `enabled`). Use `enabled: false` in the file to
disable. Deleting a loop-sourced task through the API is rejected with a 400 —
- the loop file is the removal surface.
+ the loop file is the removal surface. The scheduled-tasks UI marks loop tasks
+ as file-managed and disables their edit/enable/delete actions for the same
+ reason; `run now` remains available.
## Public exports (runtime.js)
diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js
index 1fd71be7..7d15e671 100644
--- a/packages/web/server/lib/scheduled-tasks/loops.js
+++ b/packages/web/server/lib/scheduled-tasks/loops.js
@@ -41,6 +41,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
+import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js';
const LOOP_DIR_NAME = 'loops';
const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME);
@@ -94,6 +95,12 @@ export const parseLoopDefinition = (filePath) => {
console.warn(`[loops] skipped ${filePath}: frontmatter "name" is required`);
return null;
}
+ if (name.length > MAX_TASK_NAME_LENGTH) {
+ // Reject instead of clamping: task names are clamped to this length at
+ // storage time, so identity keys must match the stored value exactly.
+ console.warn(`[loops] skipped ${filePath}: frontmatter "name" exceeds ${MAX_TASK_NAME_LENGTH} characters`);
+ return null;
+ }
const cron = asNonEmptyString(frontmatter.schedule);
if (!cron) {
diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js
index 7281adda..e05ef980 100644
--- a/packages/web/server/lib/scheduled-tasks/loops.test.js
+++ b/packages/web/server/lib/scheduled-tasks/loops.test.js
@@ -181,6 +181,33 @@ model: openai/gpt-5
await cleanup();
}
});
+
+ it('rejects names longer than the storage limit', async () => {
+ const { projectPath, cleanup } = await createProject();
+ try {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ try {
+ const filePath = path.join(projectPath, 'long-name.md');
+ await writeFile(filePath, `---
+name: ${'x'.repeat(81)}
+schedule: "0 9 * * *"
+model: openai/gpt-5
+---
+Run.
+`, 'utf8');
+
+ // Task names are clamped to 80 chars at storage time; a raw name that
+ // exceeds it could never match the stored task, so the file is treated
+ // as malformed rather than creating an unreachable definition.
+ expect(parseLoopDefinition(filePath)).toBeNull();
+ expect(warn).toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ } finally {
+ await cleanup();
+ }
+ });
});
describe('discoverLoops', () => {