@saadnvd1/agent-os 0.3.479 → 0.3.481

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,7 @@ import { land } from "./signoff";
28
28
  import { arm, disarm } from "./armed";
29
29
  import { quickChange, quickPR } from "./quick";
30
30
  import { review } from "./review";
31
+ import { cancelReminder, listReminders, remindMe } from "./reminders";
31
32
  import { parseArgs as p, type ToolName } from "./tool-schemas";
32
33
 
33
34
  export { isToolName, TOOLS, type ToolName } from "./tool-schemas";
@@ -169,6 +170,14 @@ export async function runTool(
169
170
  addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
170
171
  return `Rule ${rule.id} removed.`;
171
172
  }
173
+ case "remind_me": {
174
+ const a = p(tool, raw);
175
+ return remindMe(w, a.at, a.prompt, a.name);
176
+ }
177
+ case "reminders":
178
+ return listReminders(w);
179
+ case "cancel_reminder":
180
+ return cancelReminder(w, p(tool, raw).id);
172
181
  case "review": {
173
182
  const a = p(tool, raw);
174
183
  return reviewTarget(w, a.target, { fresh: a.fresh });
@@ -32,6 +32,9 @@ export const TOOL_NAMES = {
32
32
  sign_off: name("sign_off"),
33
33
  arm: name("arm"),
34
34
  disarm: name("disarm"),
35
+ remind_me: name("remind_me"),
36
+ reminders: name("reminders"),
37
+ cancel_reminder: name("cancel_reminder"),
35
38
  ask_saad: name("ask_saad"),
36
39
  } as const;
37
40
 
@@ -194,6 +194,23 @@ export const TOOL_SHAPES = {
194
194
  .describe("The rule, one sentence"),
195
195
  },
196
196
  rule_remove: { id: z.number().int().positive().describe("The rule's id") },
197
+ remind_me: {
198
+ at: z
199
+ .string()
200
+ .trim()
201
+ .min(1)
202
+ .max(100)
203
+ .describe('When: "tomorrow 10am", "in 2h" or 2026-10-12T10:00 (Central)'),
204
+ prompt: z
205
+ .string()
206
+ .trim()
207
+ .min(1)
208
+ .max(2000)
209
+ .describe("What to remind yourself of, in words you'll understand later"),
210
+ name: z.string().trim().min(1).max(80).optional(),
211
+ },
212
+ reminders: {},
213
+ cancel_reminder: { id: ref("The reminder's id, from reminders") },
197
214
  review: {
198
215
  target: ref("The task, or its PR (#12 or URL)"),
199
216
  fresh: z
@@ -84,6 +84,12 @@ const DESCRIPTIONS: Record<ToolName, string> = {
84
84
  'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
85
85
  rule_remove:
86
86
  "Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
87
+ remind_me:
88
+ "Set a one-off reminder for yourself in this workspace: at the time given it reaches you once as a scheduled prompt (not an approval), even if AgentOS was off then. At least 10 minutes ahead; a few at a time at most.",
89
+ reminders:
90
+ "List this workspace's upcoming reminders and the last few that ran.",
91
+ cancel_reminder:
92
+ "Cancel a reminder you set, by its id from reminders. Reminders Saad made are his to change.",
87
93
  review:
88
94
  "Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
89
95
  sign_off:
@@ -5,14 +5,24 @@ export interface SchedulesBadge {
5
5
  next: { name: string; at: number; timezone: string } | null;
6
6
  // How many schedules' last run failed.
7
7
  failed: number;
8
+ // Enabled one-offs still to run, soonest first.
9
+ upcoming: number;
10
+ nextUpcoming: { name: string; at: number; timezone: string } | null;
8
11
  }
9
12
 
10
13
  // What the sidebar's Schedules clock says at a glance.
11
14
  export function schedulesBadge(schedules: ScheduleView[]): SchedulesBadge {
12
15
  let next: SchedulesBadge["next"] = null;
13
16
  let failed = 0;
17
+ let upcoming = 0;
18
+ let nextUpcoming: SchedulesBadge["nextUpcoming"] = null;
14
19
  for (const s of schedules) {
15
20
  if (s.lastRun?.outcome === "failed") failed++;
21
+ if (s.oneOff && s.enabled && !s.done_at) {
22
+ upcoming++;
23
+ if (s.nextRunAt && (!nextUpcoming || s.nextRunAt < nextUpcoming.at))
24
+ nextUpcoming = { name: s.name, at: s.nextRunAt, timezone: s.timezone };
25
+ }
16
26
  if (
17
27
  s.enabled &&
18
28
  !s.paused &&
@@ -21,5 +31,5 @@ export function schedulesBadge(schedules: ScheduleView[]): SchedulesBadge {
21
31
  )
22
32
  next = { name: s.name, at: s.nextRunAt, timezone: s.timezone };
23
33
  }
24
- return { next, failed };
34
+ return { next, failed, upcoming, nextUpcoming };
25
35
  }
@@ -100,7 +100,7 @@ export function isTimezone(tz: string): boolean {
100
100
  }
101
101
 
102
102
  const formatters = new Map<string, Intl.DateTimeFormat>();
103
- function wall(ms: number, tz: string) {
103
+ export function wall(ms: number, tz: string) {
104
104
  let f = formatters.get(tz);
105
105
  if (!f) {
106
106
  f = new Intl.DateTimeFormat("en-US", {
@@ -7,7 +7,13 @@
7
7
 
8
8
  import { getProject } from "../projects";
9
9
  import { resolveSession } from "../bus";
10
- import { describeCron, everyCron } from "./cron";
10
+ import {
11
+ DEFAULT_TIMEZONE,
12
+ describeCron,
13
+ everyCron,
14
+ formatRunTime,
15
+ } from "./cron";
16
+ import { parseWhen } from "./when";
11
17
  import { runSlot, type RunDeps, type RunResult } from "./run";
12
18
  import { nextRunAt } from "./scheduler";
13
19
  import { realDeps } from "./start";
@@ -37,12 +43,28 @@ export interface ScheduleView extends Schedule {
37
43
  lastRun: ScheduleRun | null;
38
44
  // Its workspace's orchestrator is paused, so it skips its slots.
39
45
  paused: boolean;
46
+ oneOff: boolean;
47
+ // Who made it: Saad, or the agent session (the orchestrator, or another).
48
+ createdBy: { kind: "saad" | "orchestrator" | "session"; name: string };
49
+ }
50
+
51
+ function creatorOf(s: Schedule): ScheduleView["createdBy"] {
52
+ const creator = s.created_by_session_id
53
+ ? getSessionRow(s.created_by_session_id)
54
+ : null;
55
+ if (!creator) return { kind: "saad", name: "Saad" };
56
+ return creator.role === "orchestrator"
57
+ ? { kind: "orchestrator", name: "Orchestrator" }
58
+ : { kind: "session", name: creator.name };
40
59
  }
41
60
 
42
61
  export function scheduleView(s: Schedule, now = Date.now()): ScheduleView {
43
62
  return {
44
63
  ...s,
45
- description: describeCron(s.cron),
64
+ description:
65
+ s.run_at != null
66
+ ? `Once, ${formatRunTime(s.run_at, s.timezone)}`
67
+ : describeCron(s.cron),
46
68
  projectName: s.project_id ? (getProject(s.project_id)?.name ?? null) : null,
47
69
  targetName: s.target_session_id
48
70
  ? (getSessionRow(s.target_session_id)?.name ?? null)
@@ -50,6 +72,8 @@ export function scheduleView(s: Schedule, now = Date.now()): ScheduleView {
50
72
  nextRunAt: nextRunAt(s, now),
51
73
  lastRun: lastRun(s.id),
52
74
  paused: isPaused(s.workspace_id),
75
+ oneOff: s.run_at != null,
76
+ createdBy: creatorOf(s),
53
77
  };
54
78
  }
55
79
 
@@ -65,6 +89,8 @@ export function checkInInput(opts: {
65
89
  from?: string | null;
66
90
  every?: string;
67
91
  cron?: string;
92
+ // A one-off instead of a cron: "tomorrow 10am", "in 2h", an ISO time.
93
+ at?: string;
68
94
  prompt: string;
69
95
  name?: string;
70
96
  timezone?: string;
@@ -79,14 +105,26 @@ export function checkInInput(opts: {
79
105
  if (sessionWorkspace(creator) !== workspaceId)
80
106
  throw new Error(`${session.name} isn't in your workspace`);
81
107
  }
82
- const cron = opts.cron?.trim() || everyCron(opts.every ?? "30m");
108
+ if (opts.at && (opts.cron || opts.every))
109
+ throw new Error("Give a run time (at) or a repeat (every, cron), not both");
110
+ const tz = opts.timezone?.trim() || DEFAULT_TIMEZONE;
111
+ const runAt = opts.at ? parseWhen(opts.at, tz) : null;
112
+ const cron =
113
+ runAt === null
114
+ ? opts.cron?.trim() || everyCron(opts.every ?? "30m")
115
+ : undefined;
83
116
  // The workspace's orchestrator is kept as "its orchestrator", not this
84
117
  // session's id: each run goes to whichever session is orchestrator then.
85
118
  const orchestrator = session.role === "orchestrator";
86
119
  return {
87
120
  workspaceId,
88
- name: opts.name?.trim() || `Check-in: ${session.name}`.slice(0, 80),
121
+ name:
122
+ opts.name?.trim() ||
123
+ (runAt === null
124
+ ? `Check-in: ${session.name}`.slice(0, 80)
125
+ : reminderName(workspaceId, runAt, tz)),
89
126
  cron,
127
+ at: runAt,
90
128
  timezone: opts.timezone,
91
129
  prompt: opts.prompt,
92
130
  kind: orchestrator ? "orchestrator" : "message",
@@ -95,12 +133,30 @@ export function checkInInput(opts: {
95
133
  };
96
134
  }
97
135
 
136
+ // "Reminder Tue, Oct 7, 9:00 AM", with a number after it when that's taken.
137
+ export function reminderName(
138
+ workspaceId: string,
139
+ runAt: number,
140
+ tz = DEFAULT_TIMEZONE
141
+ ): string {
142
+ const taken = new Set(
143
+ listSchedules(workspaceId)
144
+ .filter((s) => !s.done_at)
145
+ .map((s) => s.name.toLowerCase())
146
+ );
147
+ const base = `Reminder ${formatRunTime(runAt, tz)}`;
148
+ let name = base;
149
+ for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base} (${n})`;
150
+ return name;
151
+ }
152
+
98
153
  export function scheduleHistory(id: string, limit = 50): ScheduleRun[] {
99
154
  return listRuns(id, limit);
100
155
  }
101
156
 
102
157
  // Run now: its own slot, whatever the time and while the last run is still
103
- // working. Pause holds it like any run.
158
+ // working. Pause holds it like any run. A one-off has one slot, so Run now
159
+ // uses up its only run.
104
160
  export async function runNow(
105
161
  id: string,
106
162
  deps: RunDeps = realDeps
@@ -108,6 +164,11 @@ export async function runNow(
108
164
  const schedule = getSchedule(id);
109
165
  if (!schedule || schedule.archived_at) throw new Error("Schedule not found");
110
166
  const result = await runSlot(schedule, Date.now(), "manual", deps);
111
- if (!result) throw new Error("Couldn't claim a run");
167
+ if (!result)
168
+ throw new Error(
169
+ schedule.run_at != null
170
+ ? "It already ran: edit it to a new time to run it again"
171
+ : "Couldn't claim a run"
172
+ );
112
173
  return result;
113
174
  }
@@ -3,6 +3,7 @@ import {
3
3
  attachSession,
4
4
  brakeRun,
5
5
  claimSlot,
6
+ finishOneOff,
6
7
  finishRun,
7
8
  lastSettledBefore,
8
9
  lastStarted,
@@ -60,8 +61,15 @@ export async function runSlot(
60
61
  trigger: RunTrigger,
61
62
  deps: RunDeps
62
63
  ): Promise<RunResult | null> {
64
+ // A one-off has one slot, Run now included: the claim is what stops it
65
+ // running twice.
66
+ const oneOff = schedule.run_at != null;
67
+ if (oneOff) slotAt = schedule.run_at!;
63
68
  const slot =
64
- trigger === "manual" ? `manual:${randomUUID()}` : slotKey(slotAt);
69
+ trigger === "manual" && !oneOff
70
+ ? `manual:${randomUUID()}`
71
+ : slotKey(slotAt);
72
+ if (oneOff && schedule.done_at) return null;
65
73
  const runId = claimSlot(schedule.id, slot, slotAt, trigger);
66
74
  if (runId === null) return null;
67
75
  const caughtUp = trigger === "catch-up" ? "caught up" : null;
@@ -71,6 +79,7 @@ export async function runSlot(
71
79
  sessionId: string | null = null
72
80
  ): RunResult => {
73
81
  finishRun(runId, outcome, detail, sessionId);
82
+ if (oneOff) finishOneOff(schedule.id);
74
83
  return { runId, outcome, detail, sessionId };
75
84
  };
76
85
  const joined = (...parts: (string | null)[]) =>
@@ -90,6 +99,16 @@ export async function runSlot(
90
99
  return done("failed", why);
91
100
  };
92
101
 
102
+ // A recurring run skips while paused; a one-off has only this run, so it's
103
+ // held like a brake and runs once the workspace resumes.
104
+ const paused = (): RunResult => {
105
+ if (!oneOff)
106
+ return done("skipped", joined(caughtUp, "paused with the orchestrator"));
107
+ const detail = "held: paused with the orchestrator";
108
+ brakeRun(runId, detail);
109
+ return { runId, outcome: "skipped", detail, sessionId: null };
110
+ };
111
+
93
112
  // Its session is gone and won't come back: the schedule turns itself off
94
113
  // and says so once, instead of failing every slot.
95
114
  const gone = targetGone(schedule);
@@ -100,9 +119,8 @@ export async function runSlot(
100
119
  }
101
120
  const target = targetProblem(schedule);
102
121
  if (target) return fail(target);
103
- if (pausedFor(schedule))
104
- return done("skipped", joined(caughtUp, "paused with the orchestrator"));
105
- if (trigger !== "manual") {
122
+ if (pausedFor(schedule)) return paused();
123
+ if (trigger !== "manual" && !oneOff) {
106
124
  const previous = lastStarted(schedule.id, runId);
107
125
  if (previous) {
108
126
  let busy: boolean;
@@ -128,8 +146,7 @@ export async function runSlot(
128
146
  }
129
147
 
130
148
  // Again: Saad may have paused while the overlap check waited.
131
- if (pausedFor(schedule))
132
- return done("skipped", joined(caughtUp, "paused with the orchestrator"));
149
+ if (pausedFor(schedule)) return paused();
133
150
  try {
134
151
  const started = await deps.start(schedule, (id) =>
135
152
  attachSession(runId, id)
@@ -23,14 +23,24 @@ export const LATE_MS = 90 * 1000;
23
23
 
24
24
  // The slot a schedule is due to run now, if any.
25
25
  export function dueSlot(schedule: Schedule, now: number): number | null {
26
- if (!schedule.enabled || schedule.archived_at) return null;
26
+ if (!schedule.enabled || schedule.archived_at || schedule.done_at)
27
+ return null;
28
+ // A one-off is due from its run time on, whenever AgentOS notices: no
29
+ // armed_at cut-off, so a run missed while it was off still happens.
30
+ if (schedule.run_at != null) {
31
+ if (schedule.run_at > now) return null;
32
+ return slotTaken(schedule.id, slotKey(schedule.run_at))
33
+ ? null
34
+ : schedule.run_at;
35
+ }
27
36
  const slot = prevRun(schedule.cron, now, schedule.timezone);
28
37
  if (slot === null || slot <= schedule.armed_at) return null;
29
38
  return slotTaken(schedule.id, slotKey(slot)) ? null : slot;
30
39
  }
31
40
 
32
41
  export function nextRunAt(schedule: Schedule, now = Date.now()): number | null {
33
- if (!schedule.enabled) return null;
42
+ if (!schedule.enabled || schedule.done_at) return null;
43
+ if (schedule.run_at != null) return schedule.run_at;
34
44
  return nextRun(
35
45
  schedule.cron,
36
46
  Math.max(now, schedule.armed_at),
@@ -85,15 +85,21 @@ async function startSession(
85
85
  // Marked as a schedule's, not something Saad just typed: the orchestrator's
86
86
  // brief says it never counts as an approval.
87
87
  export function scheduledMessage(
88
- schedule: Pick<Schedule, "name" | "prompt">,
88
+ schedule: Pick<Schedule, "name" | "prompt"> & { run_at?: number | null },
89
89
  projectName: string | null,
90
90
  // An agent set it up: it's that agent's request, not Saad's.
91
- creator?: Pick<Session, "id" | "name"> | null
91
+ creator?: (Pick<Session, "id" | "name"> & { role?: string | null }) | null
92
92
  ): string {
93
93
  const where = projectName ? ` for ${projectName}` : "";
94
- const who = creator
95
- ? `, set up by agent session "${creator.name}" (${creator.id.slice(0, 8)}), not by the user: a peer's request, not an approval`
96
- : ", saved in Schedules: a standing prompt, not an approval";
94
+ const once = schedule.run_at != null;
95
+ const who =
96
+ creator?.role === "orchestrator" && once
97
+ ? ", a reminder you set yourself with remind_me: your own follow-up, not an approval"
98
+ : creator
99
+ ? `, set up by agent session "${creator.name}" (${creator.id.slice(0, 8)}), not by the user: a peer's request, not an approval`
100
+ : once
101
+ ? ", a one-off reminder saved in Schedules: a prompt, not an approval"
102
+ : ", saved in Schedules: a standing prompt, not an approval";
97
103
  return `[Scheduled message "${schedule.name}"${where}${who}]\n${schedule.prompt}`;
98
104
  }
99
105
 
@@ -10,7 +10,14 @@ import {
10
10
  MESSAGE_MIN_GAP_MINUTES,
11
11
  MIN_GAP_MINUTES,
12
12
  minGapMinutes,
13
+ formatRunTime,
13
14
  } from "./cron";
15
+ import { parseWhen } from "./when";
16
+
17
+ // Agents can't aim a reminder at themselves sooner than this (a reminder
18
+ // loop), keep more than this many waiting, or set one past a year out.
19
+ export const MAX_AGENT_REMINDERS = 20;
20
+ const MAX_AHEAD_MS = 366 * 86_400_000;
14
21
 
15
22
  export const SCHEDULE_KINDS = [
16
23
  "task",
@@ -29,6 +36,7 @@ export interface Schedule {
29
36
  workspace_id: string;
30
37
  project_id: string | null;
31
38
  name: string;
39
+ // '' for a one-off (the column is NOT NULL).
32
40
  cron: string;
33
41
  timezone: string;
34
42
  prompt: string;
@@ -41,6 +49,10 @@ export interface Schedule {
41
49
  enabled: boolean;
42
50
  // Epoch ms: only slots after this run (set on create, enable, cron edit).
43
51
  armed_at: number;
52
+ // A one-off's single run time (epoch ms, UTC), or null when recurring.
53
+ run_at: number | null;
54
+ // Set when a one-off's run settled; it's off from then on.
55
+ done_at: number | null;
44
56
  archived_at: string | null;
45
57
  created_at: string;
46
58
  updated_at: string;
@@ -63,7 +75,10 @@ export interface ScheduleInput {
63
75
  workspaceId: string;
64
76
  projectId?: string | null;
65
77
  name: string;
66
- cron: string;
78
+ // A cron, or `at` for a one-off: exactly one.
79
+ cron?: string;
80
+ // A one-off's time: text parseWhen reads (in `timezone`), or epoch ms.
81
+ at?: string | number | null;
67
82
  timezone?: string;
68
83
  prompt: string;
69
84
  kind: ScheduleKind;
@@ -93,6 +108,30 @@ export function listSchedules(workspaceId?: string | null): Schedule[] {
93
108
  return rows.map(toSchedule);
94
109
  }
95
110
 
111
+ export const isOneOff = (s: Pick<Schedule, "run_at">): boolean =>
112
+ s.run_at != null;
113
+
114
+ // One-offs still waiting to run, soonest first.
115
+ export function upcomingOneOffs(workspaceId?: string | null): Schedule[] {
116
+ return listSchedules(workspaceId)
117
+ .filter((s) => isOneOff(s) && s.enabled && !s.done_at)
118
+ .sort((a, b) => a.run_at! - b.run_at!);
119
+ }
120
+
121
+ // Turns a one-off off once its run settled. True only for the call that did.
122
+ const byOrchestrator = (sessionId: string | null | undefined) =>
123
+ !!sessionId && getSessionRow(sessionId)?.role === "orchestrator";
124
+
125
+ export function finishOneOff(id: string, now = Date.now()): boolean {
126
+ return (
127
+ db
128
+ .prepare(
129
+ `UPDATE schedules SET enabled = 0, done_at = ?, updated_at = datetime('now') WHERE id = ? AND done_at IS NULL`
130
+ )
131
+ .run(now, id).changes === 1
132
+ );
133
+ }
134
+
96
135
  export function getSchedule(id: string): Schedule | null {
97
136
  const row = db.prepare(`SELECT * FROM schedules WHERE id = ?`).get(id) as
98
137
  | Row
@@ -164,10 +203,18 @@ export function sessionTargetProblem(
164
203
  return null;
165
204
  }
166
205
 
206
+ type Valid = Required<
207
+ Omit<ScheduleInput, "createdBySessionId" | "cron" | "at">
208
+ > & { cron: string; runAt: number | null };
209
+
210
+ // `keepPast`: a run time already accepted, allowed to stay once it has passed
211
+ // (renaming a done one-off).
167
212
  function validate(
168
213
  input: ScheduleInput,
169
- selfId?: string
170
- ): Required<Omit<ScheduleInput, "createdBySessionId">> {
214
+ selfId?: string,
215
+ now = Date.now(),
216
+ keepPast: number | null = null
217
+ ): Valid {
171
218
  const name = input.name?.trim();
172
219
  if (!name) throw new Error("Give the schedule a name");
173
220
  if (name.length > 80) throw new Error("Keep the name under 80 characters");
@@ -178,11 +225,50 @@ function validate(
178
225
  if (!prompt) throw new Error("Write the prompt it runs");
179
226
  if (!SCHEDULE_KINDS.includes(input.kind))
180
227
  throw new Error("Pick task, session, orchestrator or message");
181
- const cron = input.cron?.trim().replace(/\s+/g, " ");
182
- const bad = cronError(cron ?? "");
183
- if (bad) throw new Error(bad);
184
228
  const timezone = input.timezone?.trim() || DEFAULT_TIMEZONE;
185
229
  if (!isTimezone(timezone)) throw new Error(`Unknown time zone "${timezone}"`);
230
+ const cronText = input.cron?.trim().replace(/\s+/g, " ") ?? "";
231
+ const oneOff = input.at != null && input.at !== "";
232
+ if (oneOff && cronText)
233
+ throw new Error("Give a cron or a run time, not both");
234
+ if (!oneOff && !cronText) throw new Error("Needs a cron or a run time");
235
+ const runAt = oneOff
236
+ ? typeof input.at === "number"
237
+ ? input.at
238
+ : parseWhen(input.at as string, timezone, now)
239
+ : null;
240
+ if (runAt !== null) {
241
+ if (!Number.isFinite(runAt)) throw new Error("Pick a run time");
242
+ if (runAt <= now && runAt !== keepPast)
243
+ throw new Error(
244
+ `That time has passed (${formatRunTime(runAt, timezone)}): pick a time ahead`
245
+ );
246
+ if (runAt - now > MAX_AHEAD_MS)
247
+ throw new Error("A one-off can be at most a year ahead");
248
+ if (
249
+ input.createdBySessionId &&
250
+ runAt !== keepPast &&
251
+ runAt - now < MESSAGE_MIN_GAP_MINUTES * 60_000
252
+ )
253
+ throw new Error(
254
+ `A reminder must be at least ${MESSAGE_MIN_GAP_MINUTES} minutes ahead`
255
+ );
256
+ // The cap is on the orchestrator's remind_me reminders; a session's own
257
+ // `aos schedule add --at` isn't counted.
258
+ if (
259
+ !selfId &&
260
+ byOrchestrator(input.createdBySessionId) &&
261
+ upcomingOneOffs(input.workspaceId).filter((s) =>
262
+ byOrchestrator(s.created_by_session_id)
263
+ ).length >= MAX_AGENT_REMINDERS
264
+ )
265
+ throw new Error(
266
+ `At most ${MAX_AGENT_REMINDERS} reminders can wait at once: cancel one first`
267
+ );
268
+ }
269
+ const cron = cronText;
270
+ const bad = oneOff ? null : cronError(cron);
271
+ if (bad) throw new Error(bad);
186
272
  if (!getWorkspace(input.workspaceId)) throw new Error("Pick a workspace");
187
273
  const message = input.kind === "message";
188
274
  // A message goes to a session, whatever project it's in.
@@ -208,6 +294,7 @@ function validate(
208
294
  throw new Error("A task or session schedule needs a project");
209
295
  }
210
296
  if (
297
+ !oneOff &&
211
298
  (input.kind === "task" || input.kind === "session") &&
212
299
  minGapMinutes(cron, timezone) < MIN_GAP_MINUTES
213
300
  )
@@ -218,6 +305,7 @@ function validate(
218
305
  const agentCheckIn =
219
306
  input.kind === "orchestrator" && !!input.createdBySessionId;
220
307
  if (
308
+ !oneOff &&
221
309
  (message || agentCheckIn) &&
222
310
  minGapMinutes(cron, timezone) < MESSAGE_MIN_GAP_MINUTES
223
311
  )
@@ -225,7 +313,10 @@ function validate(
225
313
  `A message schedule runs at most every ${MESSAGE_MIN_GAP_MINUTES} minutes`
226
314
  );
227
315
  const clash = listSchedules(input.workspaceId).find(
228
- (s) => s.id !== selfId && s.name.toLowerCase() === name.toLowerCase()
316
+ (s) =>
317
+ s.id !== selfId &&
318
+ !s.done_at &&
319
+ s.name.toLowerCase() === name.toLowerCase()
229
320
  );
230
321
  if (clash)
231
322
  throw new Error(`There's already a schedule called "${clash.name}"`);
@@ -239,6 +330,7 @@ function validate(
239
330
  kind: input.kind,
240
331
  targetSessionId,
241
332
  enabled: input.enabled ?? true,
333
+ runAt,
242
334
  };
243
335
  }
244
336
 
@@ -246,11 +338,11 @@ export function createSchedule(
246
338
  input: ScheduleInput,
247
339
  now = Date.now()
248
340
  ): Schedule {
249
- const v = validate(input);
341
+ const v = validate(input, undefined, now);
250
342
  const id = randomUUID();
251
343
  db.prepare(
252
- `INSERT INTO schedules (id, workspace_id, project_id, name, cron, timezone, prompt, kind, target_session_id, created_by_session_id, enabled, armed_at)
253
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
344
+ `INSERT INTO schedules (id, workspace_id, project_id, name, cron, timezone, prompt, kind, target_session_id, created_by_session_id, enabled, armed_at, run_at)
345
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
254
346
  ).run(
255
347
  id,
256
348
  v.workspaceId,
@@ -263,7 +355,8 @@ export function createSchedule(
263
355
  v.targetSessionId,
264
356
  input.createdBySessionId ?? null,
265
357
  v.enabled ? 1 : 0,
266
- now
358
+ now,
359
+ v.runAt
267
360
  );
268
361
  return getSchedule(id)!;
269
362
  }
@@ -281,7 +374,11 @@ export function updateSchedule(
281
374
  projectId:
282
375
  patch.projectId === undefined ? current.project_id : patch.projectId,
283
376
  name: patch.name ?? current.name,
284
- cron: patch.cron ?? current.cron,
377
+ // A patch with a cron makes it recurring; one with `at`, a one-off.
378
+ cron:
379
+ patch.cron ??
380
+ (patch.at != null ? undefined : current.cron || undefined),
381
+ at: patch.at ?? (patch.cron != null ? null : current.run_at),
285
382
  timezone: patch.timezone ?? current.timezone,
286
383
  prompt: patch.prompt ?? current.prompt,
287
384
  kind: patch.kind ?? current.kind,
@@ -292,17 +389,27 @@ export function updateSchedule(
292
389
  enabled: patch.enabled ?? current.enabled,
293
390
  createdBySessionId: current.created_by_session_id,
294
391
  },
295
- id
392
+ id,
393
+ now,
394
+ current.done_at || current.enabled ? current.run_at : null
296
395
  );
396
+ // A one-off moved to a new time opens again, even if it had run.
397
+ const reopen = v.runAt !== null && v.runAt !== current.run_at;
398
+ const enabled = reopen
399
+ ? (patch.enabled ?? true)
400
+ : v.runAt !== null && current.done_at
401
+ ? false
402
+ : v.enabled;
297
403
  // A new time or a fresh enable starts counting from now: no slot from
298
404
  // before it is "missed".
299
405
  const rearm =
300
406
  v.cron !== current.cron ||
407
+ v.runAt !== current.run_at ||
301
408
  v.timezone !== current.timezone ||
302
- (v.enabled && !current.enabled);
409
+ (enabled && !current.enabled);
303
410
  db.prepare(
304
411
  `UPDATE schedules SET workspace_id = ?, project_id = ?, name = ?, cron = ?, timezone = ?,
305
- prompt = ?, kind = ?, target_session_id = ?, enabled = ?, armed_at = ?, updated_at = datetime('now')
412
+ prompt = ?, kind = ?, target_session_id = ?, enabled = ?, armed_at = ?, run_at = ?, done_at = ?, updated_at = datetime('now')
306
413
  WHERE id = ?`
307
414
  ).run(
308
415
  v.workspaceId,
@@ -313,8 +420,10 @@ export function updateSchedule(
313
420
  v.prompt,
314
421
  v.kind,
315
422
  v.targetSessionId,
316
- v.enabled ? 1 : 0,
423
+ enabled ? 1 : 0,
317
424
  rearm ? now : current.armed_at,
425
+ v.runAt,
426
+ reopen || v.runAt === null ? null : current.done_at,
318
427
  id
319
428
  );
320
429
  return getSchedule(id)!;
@@ -546,6 +655,14 @@ export function failAbandonedClaims(
546
655
  now = Date.now()
547
656
  ): number {
548
657
  const before = Math.floor((now - olderThanMs) / 1000);
658
+ // A one-off whose only run was cut off won't come round again: finish it
659
+ // here so it leaves Upcoming and stops counting toward the reminder cap.
660
+ db.prepare(
661
+ `UPDATE schedules SET enabled = 0, done_at = ?, updated_at = datetime('now')
662
+ WHERE run_at IS NOT NULL AND done_at IS NULL AND id IN (
663
+ SELECT schedule_id FROM schedule_runs
664
+ WHERE outcome = 'claimed' AND created_at < datetime(?, 'unixepoch'))`
665
+ ).run(now, before);
549
666
  const started = db
550
667
  .prepare(
551
668
  `UPDATE schedule_runs SET outcome = 'started', detail = 'AgentOS restarted while it was starting'