diff --git a/frontend/packages/dashboard/src/schedules.js b/frontend/packages/dashboard/src/schedules.js
new file mode 100644
index 00000000..7b2eabb3
--- /dev/null
+++ b/frontend/packages/dashboard/src/schedules.js
@@ -0,0 +1,1074 @@
+// Dashboard SCH3DUL3S tab — reminders + scheduled-prompts.
+//
+// Reminders (a separate sqlite table, not part of /api/state) and the
+// operator's scheduled prompts: the lists, the inline create row, the
+// per-row edit form, and the fire-now / cancel actions. Live updates
+// arrive via the `reminders_changed` / `schedules_changed` dashboard
+// events (wired into the entry's mutation dispatch table); the
+// tab-activation re-fetch is the cold-load / reconnect recovery path.
+//
+// Owns its own module state (the schedules list + edit-in-progress
+// tracking); reads the shared agent roster from state.js for the
+// target-chip pickers. Render + format helpers come from util.js.
+
+import { $, el } from './common.js';
+import { paintAtomic, fmtAgo, fmtDuration } from './util.js';
+import { containersState } from './state.js';
+
+export async function refreshReminders() {
+ const liveRoot = $('reminders-section');
+ if (!liveRoot) return;
+ try {
+ const resp = await fetch('/api/reminders');
+ if (!resp.ok) {
+ paintAtomic(liveRoot, (root) => {
+ root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
+ });
+ return;
+ }
+ const rows = await resp.json();
+ renderReminders(rows);
+ } catch (err) {
+ paintAtomic(liveRoot, (root) => {
+ root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
+ });
+ }
+}
+function renderReminders(rows) {
+ const liveRoot = $('reminders-section');
+ if (!liveRoot) return;
+ paintAtomic(liveRoot, (root) => {
+ if (!rows.length) {
+ root.append(el('p', { class: 'empty' }, 'no queued reminders'));
+ return;
+ }
+ const ul = el('ul', { class: 'reminders' });
+ for (const r of rows) {
+ const failed = (r.attempt_count || 0) > 0;
+ const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
+ const dueIn = r.due_at - Math.floor(Date.now() / 1000);
+ const dueLabel = dueIn <= 0
+ ? `overdue ${fmtAgo(r.due_at)}`
+ : `in ${fmtDuration(dueIn)}`;
+ const head = el('div', { class: 'reminder-head' },
+ el('span', { class: 'agent' }, r.agent), ' ',
+ el('span', {
+ class: 'meta reminder-due',
+ title: new Date(r.due_at * 1000).toISOString(),
+ 'data-due-at': String(r.due_at),
+ }, dueLabel),
+ ' ',
+ el('span', { class: 'meta' }, `· id ${r.id}`),
+ );
+ if (r.file_path) {
+ head.append(' ', el('span', { class: 'meta' }, '· payload → '));
+ appendLinkified(head, r.file_path);
+ }
+ if (failed) {
+ head.append(' ', el('span',
+ {
+ class: 'badge badge-warn',
+ title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
+ },
+ `⚠ ${r.attempt_count} failed`));
+ }
+ const body = el('div', { class: 'reminder-body' });
+ appendLinkified(body, r.message);
+ li.append(head, body);
+ if (r.last_error) {
+ li.append(el('div', { class: 'reminder-error' },
+ el('span', { class: 'msg-sep' }, 'error: '),
+ r.last_error,
+ ));
+ }
+ const actions = el('div', { class: 'reminder-actions' });
+ if (failed) {
+ // Retry resets the failure counters so the scheduler picks
+ // the row up again on its next 5s tick. No data-no-refresh
+ // — the resulting refreshState re-fires refreshReminders.
+ const retryForm = el('form', {
+ method: 'POST', action: '/retry-reminder/' + r.id,
+ class: 'inline', 'data-async': '',
+ });
+ retryForm.append(el('button',
+ { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
+ actions.append(retryForm);
+ }
+ const cancelForm = el('form', {
+ method: 'POST', action: '/cancel-reminder/' + r.id,
+ class: 'inline', 'data-async': '',
+ 'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
+ });
+ cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
+ actions.append(cancelForm);
+ li.append(actions);
+ ul.append(li);
+ }
+ root.append(ul);
+ });
+}
+// ─── scheduled prompts ─────────────────────────────────────────────────
+// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
+// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
+// (whole or per-target), `/api/schedules/{id}` (PATCH edit),
+// `/api/schedules/{id}/fire-now` (POST). Mutations now emit a
+// `schedules_changed` SSE event so the list updates live;
+// `applySchedulesChanged` handles it. Tab-activation re-fetch kept as
+// a safety net for approval-path inserts and disconnect windows.
+// Local cache lets `refreshTabCounts` show the active count without
+// re-fetching every second.
+let schedulesState = [];
+// Schedule ids whose inline edit form is currently open. The set
+// survives across refreshSchedules() calls so a state
+// poll doesn't yank the form out from under the operator. Per-id
+// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
+// also rides the refresh.
+const editingSchedules = new Set();
+const scheduleEditCarry = new Map();
+export async function refreshSchedules() {
+ const listRoot = $('schedules-section');
+ if (!listRoot) return;
+ try {
+ const resp = await fetch('/api/schedules');
+ if (!resp.ok) {
+ paintAtomic(listRoot, (root) => {
+ root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
+ });
+ return;
+ }
+ schedulesState = await resp.json();
+ } catch (err) {
+ paintAtomic(listRoot, (root) => {
+ root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
+ });
+ return;
+ }
+ renderSchedulesList();
+}
+// Active = at least one target still alive (no `cancelled_at_unix`)
+// AND the whole schedule isn't cancelled. Drives the tab pill count.
+export function activeScheduleCount() {
+ let n = 0;
+ for (const s of schedulesState) {
+ if (s.cancelled_at_unix) continue;
+ if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
+ }
+ return n;
+}
+// Label + caption wrapper shared by every field on the schedule
+// forms — ``. Variadic
+// children land inside the label after the caption span, which is
+// what every caller wants (the input + any preset/parts/preview
+// sub-rows live next to the caption).
+function scheduleField(labelText, ...children) {
+ return el('label', { class: 'schedule-field' },
+ el('span', { class: 'schedule-field-label' }, labelText),
+ ...children);
+}
+
+// Interval composer — shared between the new-schedule form and
+// the edit-schedule form. Builds the preset chip row +
+// d/h/m/s sub-fields + live preview, and returns helpers to read
+// and write the value.
+//
+// The composer carries no opinion on what "0 / blank" means at the
+// semantic layer: it just reports the integer total. Callers decide
+// whether to treat 0 as `null` (one-shot) for their own submit path.
+function buildIntervalComposer({
+ label = 'interval (blank / all-zero = one-shot)',
+ namePrefix = 'interval_',
+ initialSeconds = 0,
+} = {}) {
+ const wrapper = scheduleField(label);
+ const presets = [
+ ['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
+ ['1h', 3600], ['6h', 21600], ['12h', 43200],
+ ['1d', 86400], ['7d', 604800],
+ ];
+ const presetsRow = el('div', { class: 'schedule-interval-presets' });
+ for (const [presetLabel, secs] of presets) {
+ presetsRow.append(el('button', {
+ type: 'button',
+ class: 'btn btn-interval-preset',
+ 'data-secs': String(secs),
+ }, presetLabel));
+ }
+ presetsRow.append(el('button', {
+ type: 'button',
+ class: 'btn btn-interval-preset btn-interval-oneshot',
+ 'data-secs': '0',
+ }, 'one-shot'));
+ wrapper.append(presetsRow);
+
+ const partsRow = el('div', { class: 'schedule-interval-parts' });
+ const mkPart = (suffix, unit) => {
+ const inp = el('input', {
+ type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0',
+ class: 'schedule-interval-num',
+ });
+ partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
+ return inp;
+ };
+ const dInp = mkPart('d', 'd');
+ const hInp = mkPart('h', 'h');
+ const mInp = mkPart('m', 'm');
+ const sInp = mkPart('s', 's');
+ wrapper.append(partsRow);
+
+ const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
+ wrapper.append(preview);
+
+ function getSeconds() {
+ const n = (v) => {
+ const x = parseInt(v, 10);
+ return Number.isFinite(x) && x > 0 ? x : 0;
+ };
+ return n(dInp.value) * 86400 + n(hInp.value) * 3600
+ + n(mInp.value) * 60 + n(sInp.value);
+ }
+ function fillFromSeconds(total) {
+ const t = Math.max(0, Math.floor(total));
+ const d = Math.floor(t / 86400);
+ const h = Math.floor((t % 86400) / 3600);
+ const m = Math.floor((t % 3600) / 60);
+ const s = t % 60;
+ dInp.value = d > 0 ? String(d) : '';
+ hInp.value = h > 0 ? String(h) : '';
+ mInp.value = m > 0 ? String(m) : '';
+ sInp.value = s > 0 ? String(s) : '';
+ }
+ function setParts({ d, h, m, s }) {
+ if (d !== undefined) dInp.value = d;
+ if (h !== undefined) hInp.value = h;
+ if (m !== undefined) mInp.value = m;
+ if (s !== undefined) sInp.value = s;
+ }
+ function updatePreview() {
+ const total = getSeconds();
+ preview.textContent = total > 0
+ ? '↻ every ' + fmtDuration(total)
+ : 'one-shot (fires once at first-fire time)';
+ preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
+ }
+ for (const inp of [dInp, hInp, mInp, sInp]) {
+ inp.addEventListener('input', updatePreview);
+ }
+ for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
+ b.addEventListener('click', () => {
+ fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0);
+ updatePreview();
+ });
+ }
+ if (initialSeconds > 0) fillFromSeconds(initialSeconds);
+ updatePreview();
+
+ return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
+}
+
+// Parse the d/h/m/s sub-fields of a `buildIntervalComposer` instance
+// out of submitted FormData. Returns total seconds, or NaN if any
+// field carries a non-integer / negative value — the caller alerts
+// and bails. Total `0` means "one-shot" at the call site (wire is
+// `interval_seconds: null`). `namePrefix` matches the prefix passed
+// to `buildIntervalComposer` (e.g. `interval_` or `edit_interval_`).
+function intervalSecondsFromFormData(fd, namePrefix) {
+ const part = (suffix, mult) => {
+ const raw = String(fd.get(namePrefix + suffix) || '').trim();
+ if (!raw) return 0;
+ const n = parseInt(raw, 10);
+ if (!Number.isFinite(n) || n < 0) return NaN;
+ return n * mult;
+ };
+ return part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
+}
+
+// Targets multi-select chip box — shared between the new-schedule
+// form and the edit-schedule form. Same DOM shape, same candidate
+// list (containers + operator + root); only the chip element id
+// prefix and checkbox field name vary. Used to be inlined twice in
+// near-identical 18-line blocks; consolidated here so a future
+// change (new chip kind, candidate-list source swap, etc.) lives in
+// one place. Returns the wrapping `