@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.
package/README.md CHANGED
@@ -745,6 +745,22 @@ next minute until the brake lifts or a newer run is due (Run now obeys them
745
745
  too, once). A schedule whose project has moved to another workspace fails
746
746
  until it's edited. Removing a schedule keeps its history.
747
747
 
748
+ **One-off schedules.** Pick **Once** in the form (a date and time in the
749
+ schedule's zone), or `aos schedule add --at "tomorrow 10am" ...` (an ISO time,
750
+ `in 2h`, a weekday; America/Chicago unless `--tz`). It works with every kind,
751
+ runs once and then turns itself off; the run stays in its history, marked
752
+ done, and the schedule moves to the **Done** group. Upcoming one-offs are
753
+ listed first with their run time, how far off it is and who made it (you or an
754
+ orchestrator), each with Run now, Edit and Cancel; the count shows in the
755
+ sidebar menu and the orchestrator's header. The same claim-in-SQLite rule
756
+ applies, so it never runs twice, and one AgentOS missed while off runs when it
757
+ returns, marked **caught up**. Pause holds a one-off until you resume rather
758
+ than skipping it, and the brakes apply as for any run. A past time is refused.
759
+ Run now uses up its only run; edit it to a new time to run it again. An
760
+ orchestrator makes its own with `remind_me`: at least ten minutes ahead, 20
761
+ pending at most, in its own workspace, and it arrives as a scheduled prompt,
762
+ never an approval.
763
+
748
764
  Task and session schedules run at most hourly, message schedules at most
749
765
  every 10 minutes and skip a run (`still working`) while their session is
750
766
  still busy, mid-turn or holding a queued message. A task schedule waits
@@ -993,7 +1009,7 @@ it may do on its own, and the lines that always come back to you as asks.
993
1009
  workspace, or the project already at that path moves in from no workspace;
994
1010
  folders that hold secrets and projects of another workspace are refused),
995
1011
  `note`,
996
- `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm` and `ask_saad`. All are served in-process to its chat, refuse
1012
+ `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm`, `remind_me`, `reminders`, `cancel_reminder` and `ask_saad`. All are served in-process to its chat, refuse
997
1013
  any target outside its workspace, and validate their arguments. The one
998
1014
  exception is other orchestrators: `orchestrators` lists every workspace and
999
1015
  whether it has one, and `message_orchestrator` messages another workspace's
@@ -28,6 +28,7 @@ export async function POST(request: NextRequest) {
28
28
  from: body.from ? String(body.from) : null,
29
29
  every: body.every,
30
30
  cron: body.cron,
31
+ at: body.at,
31
32
  prompt: String(body.prompt ?? ""),
32
33
  name: body.name,
33
34
  timezone: body.timezone,
@@ -8,6 +8,8 @@ import { usePauseOrchestrator } from "@/data/orchestrators";
8
8
  import { useOrchestratorHeader } from "@/hooks/useOrchestratorHeader";
9
9
  import { headerParts } from "@/lib/orchestrator/header-line";
10
10
  import { cn } from "@/lib/utils";
11
+ import { useSchedulesGlance } from "@/components/SessionList/useSchedulesGlance";
12
+ import { schedulesUiActions } from "@/stores/schedulesUi";
11
13
  import { NO_PASSKEYS_HERE, passkeysHere } from "@/data/presence";
12
14
  import { AsksList } from "./AsksList";
13
15
 
@@ -21,6 +23,7 @@ export const OrchestratorBar = memo(function OrchestratorBar({
21
23
  }) {
22
24
  const { overview, counts } = useOrchestratorHeader(workspaceId);
23
25
  const pause = usePauseOrchestrator(workspaceId);
26
+ const { upcomingText } = useSchedulesGlance(workspaceId);
24
27
  const [open, setOpen] = useState(true);
25
28
  const canProve = useSyncExternalStore(
26
29
  () => () => {},
@@ -73,6 +76,15 @@ export const OrchestratorBar = memo(function OrchestratorBar({
73
76
  {counts.paused ? "Resume" : "Pause"}
74
77
  </Button>
75
78
  </div>
79
+ {upcomingText && (
80
+ <button
81
+ type="button"
82
+ onClick={() => schedulesUiActions.open(workspaceId)}
83
+ className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center px-1 text-left text-xs md:min-h-8"
84
+ >
85
+ {upcomingText}
86
+ </button>
87
+ )}
76
88
  {counts.paused && (
77
89
  <p className="text-muted-foreground px-1 text-xs">
78
90
  Paused: events wait and it won&apos;t start, merge or message anything
@@ -31,8 +31,10 @@ import {
31
31
  minGapMinutes,
32
32
  nextRuns,
33
33
  presetCron,
34
+ wall,
34
35
  type PresetKind,
35
36
  } from "@/lib/schedules/cron";
37
+ import { parseWhen, relativeTime } from "@/lib/schedules/when";
36
38
 
37
39
  const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
38
40
  {
@@ -57,7 +59,10 @@ const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
57
59
  },
58
60
  ];
59
61
 
60
- const PRESETS: { value: PresetKind | "custom"; label: string }[] = [
62
+ type Preset = PresetKind | "custom" | "once";
63
+
64
+ const PRESETS: { value: Preset; label: string }[] = [
65
+ { value: "once", label: "Once" },
61
66
  { value: "every", label: "Every…" },
62
67
  { value: "hourly", label: "Hourly" },
63
68
  { value: "daily", label: "Daily" },
@@ -115,6 +120,17 @@ function Segmented<T extends string>({
115
120
  );
116
121
  }
117
122
 
123
+ const pad = (n: number) => String(n).padStart(2, "0");
124
+
125
+ // The date and time inputs' values for an instant, as the zone's wall clock.
126
+ function wallParts(ms: number, tz: string) {
127
+ const w = wall(ms, tz);
128
+ return {
129
+ date: `${w.y}-${pad(w.m)}-${pad(w.d)}`,
130
+ time: `${pad(w.h)}:${pad(w.mi)}`,
131
+ };
132
+ }
133
+
118
134
  const Field = ({
119
135
  label,
120
136
  children,
@@ -148,7 +164,8 @@ export function ScheduleForm({
148
164
  const update = useUpdateSchedule();
149
165
  const mutation = existing ? update : create;
150
166
 
151
- const initialPreset = existing ? cronPreset(existing.cron) : null;
167
+ const initialPreset =
168
+ existing && existing.run_at == null ? cronPreset(existing.cron) : null;
152
169
  const [wsId, setWsId] = useState(
153
170
  existing?.workspace_id ?? workspaceId ?? workspaces[0]?.id ?? ""
154
171
  );
@@ -161,9 +178,24 @@ export function ScheduleForm({
161
178
  );
162
179
  const [projectId, setProjectId] = useState(existing?.project_id ?? "");
163
180
  const [prompt, setPrompt] = useState(existing?.prompt ?? "");
164
- const [preset, setPreset] = useState<PresetKind | "custom">(
165
- existing ? (initialPreset?.kind ?? "custom") : draft ? "every" : "weekdays"
181
+ const [preset, setPreset] = useState<Preset>(
182
+ existing?.run_at != null
183
+ ? "once"
184
+ : existing
185
+ ? (initialPreset?.kind ?? "custom")
186
+ : draft
187
+ ? "every"
188
+ : "weekdays"
189
+ );
190
+ // A new one-off starts an hour out.
191
+ const [initialAt] = useState(() =>
192
+ wallParts(
193
+ existing?.run_at ?? Date.now() + 3_600_000,
194
+ existing?.timezone ?? DEFAULT_TIMEZONE
195
+ )
166
196
  );
197
+ const [atDate, setAtDate] = useState(initialAt.date);
198
+ const [atTime, setAtTime] = useState(initialAt.time);
167
199
  const [every, setEvery] = useState(initialPreset?.every ?? 30);
168
200
  const [hour, setHour] = useState(initialPreset?.hour ?? 9);
169
201
  const [minute, setMinute] = useState(initialPreset?.minute ?? 0);
@@ -197,12 +229,14 @@ export function ScheduleForm({
197
229
  .sort((a, b) => a.name.localeCompare(b.name));
198
230
  const target = targets.find((s) => s.id === targetId)?.id ?? "";
199
231
 
200
- const cron =
201
- preset === "custom"
232
+ const once = preset === "once";
233
+ const cron = once
234
+ ? ""
235
+ : preset === "custom"
202
236
  ? custom.trim()
203
237
  : presetCron({ kind: preset, hour, minute, weekday, every });
204
238
  const tzOk = isTimezone(timezone);
205
- const cronProblem = cronError(cron);
239
+ const cronProblem = once ? null : cronError(cron);
206
240
  // The preview counts from now, kept fresh while the form is open.
207
241
  const [openedAt, setOpenedAt] = useState(() => Date.now());
208
242
  useEffect(() => {
@@ -214,13 +248,32 @@ export function ScheduleForm({
214
248
  [cron, cronProblem, timezone, tzOk, openedAt]
215
249
  );
216
250
 
251
+ // The picked wall time in the zone, or null while it's incomplete.
252
+ const runAt = useMemo(() => {
253
+ if (!once || !tzOk || !atDate || !atTime) return null;
254
+ try {
255
+ return parseWhen(`${atDate}T${atTime}`, timezone);
256
+ } catch {
257
+ return null;
258
+ }
259
+ }, [once, tzOk, atDate, atTime, timezone]);
260
+ // Saving a done one-off at its old time can't reopen it; a new time must be ahead.
261
+ const atProblem =
262
+ once && tzOk
263
+ ? runAt === null
264
+ ? "Pick a date and time"
265
+ : runAt <= openedAt && runAt !== existing?.run_at
266
+ ? "That time has passed"
267
+ : null
268
+ : null;
269
+
217
270
  const submit = (e: React.FormEvent) => {
218
271
  e.preventDefault();
219
272
  const input = {
220
273
  workspaceId: workspace,
221
274
  projectId: project || null,
222
275
  name,
223
- cron,
276
+ ...(once ? { at: runAt } : { cron }),
224
277
  timezone,
225
278
  prompt,
226
279
  kind,
@@ -242,10 +295,11 @@ export function ScheduleForm({
242
295
  const tooOften = useMemo(
243
296
  () =>
244
297
  minGap > 0 &&
298
+ !once &&
245
299
  !cronProblem &&
246
300
  tzOk &&
247
301
  minGapMinutes(cron, timezone, openedAt) < minGap,
248
- [minGap, cron, cronProblem, timezone, tzOk, openedAt]
302
+ [minGap, once, cron, cronProblem, timezone, tzOk, openedAt]
249
303
  );
250
304
 
251
305
  const ready =
@@ -253,6 +307,7 @@ export function ScheduleForm({
253
307
  prompt.trim() &&
254
308
  workspace &&
255
309
  !cronProblem &&
310
+ !atProblem &&
256
311
  tzOk &&
257
312
  !tooOften &&
258
313
  (kind === "message" ? target : kind === "orchestrator" || project);
@@ -362,7 +417,24 @@ export function ScheduleForm({
362
417
  setPreset(v);
363
418
  }}
364
419
  />
365
- {preset === "custom" ? (
420
+ {once ? (
421
+ <div className="flex flex-wrap gap-2">
422
+ <Input
423
+ type="date"
424
+ aria-label="Date"
425
+ value={atDate}
426
+ onChange={(e) => setAtDate(e.target.value)}
427
+ className="h-11 w-44 md:h-9"
428
+ />
429
+ <Input
430
+ type="time"
431
+ aria-label="Time"
432
+ value={atTime}
433
+ onChange={(e) => setAtTime(e.target.value)}
434
+ className="h-11 w-32 md:h-9"
435
+ />
436
+ </div>
437
+ ) : preset === "custom" ? (
366
438
  <Input
367
439
  aria-label="Cron expression"
368
440
  value={custom}
@@ -450,10 +522,20 @@ export function ScheduleForm({
450
522
  className="text-muted-foreground h-11 md:h-8 md:text-xs"
451
523
  />
452
524
  <div className="bg-primary/[0.06] rounded-lg px-3 py-2.5 text-sm">
453
- {cronProblem || !tzOk ? (
525
+ {cronProblem || !tzOk || atProblem ? (
454
526
  <p className="text-destructive">
455
- {cronProblem ?? `Unknown time zone "${timezone}"`}
527
+ {!tzOk
528
+ ? `Unknown time zone "${timezone}"`
529
+ : (cronProblem ?? atProblem)}
456
530
  </p>
531
+ ) : once && runAt !== null ? (
532
+ <>
533
+ <p className="font-medium">{formatRunTime(runAt, timezone)}</p>
534
+ <p className="text-muted-foreground mt-1 text-xs">
535
+ Runs once, {relativeTime(runAt, openedAt)}, then turns itself
536
+ off.
537
+ </p>
538
+ </>
457
539
  ) : (
458
540
  <>
459
541
  <p className="font-medium">{describeCron(cron)}</p>
@@ -25,6 +25,7 @@ import {
25
25
  } from "@/data/schedules";
26
26
  import type { ScheduleView } from "@/lib/schedules";
27
27
  import { formatRunTime } from "@/lib/schedules/cron";
28
+ import { relativeTime } from "@/lib/schedules/when";
28
29
  import { schedulesUi, schedulesUiActions } from "@/stores/schedulesUi";
29
30
  import { ScheduleForm } from "./ScheduleForm";
30
31
  import { OutcomeChip, RunHistory } from "./RunHistory";
@@ -50,10 +51,14 @@ const where = (s: ScheduleView) =>
50
51
  : "";
51
52
 
52
53
  function NextRun({ s }: { s: ScheduleView }) {
54
+ if (s.done_at) return <>Done {formatRunTime(s.done_at, s.timezone)}</>;
53
55
  if (!s.enabled) return <>Off</>;
54
56
  if (s.paused) return <>Paused with the orchestrator</>;
55
57
  return s.nextRunAt ? (
56
- <>Next {formatRunTime(s.nextRunAt, s.timezone)}</>
58
+ <>
59
+ {s.oneOff ? "Runs" : "Next"} {formatRunTime(s.nextRunAt, s.timezone)}
60
+ {s.oneOff && ` (${relativeTime(s.nextRunAt)})`}
61
+ </>
57
62
  ) : (
58
63
  <>No upcoming run</>
59
64
  );
@@ -98,15 +103,44 @@ function ScheduleRow({ s, onOpen }: { s: ScheduleView; onOpen: () => void }) {
98
103
  </span>
99
104
  </div>
100
105
  <p className="text-muted-foreground truncate text-xs">
101
- {s.description} · <NextRun s={s} />
106
+ {s.oneOff ? (
107
+ <NextRun s={s} />
108
+ ) : (
109
+ <>
110
+ {s.description} · <NextRun s={s} />
111
+ </>
112
+ )}
113
+ {s.oneOff && ` · by ${s.createdBy.name}`}
102
114
  </p>
103
115
  </div>
104
116
  {s.lastRun && <OutcomeChip run={s.lastRun} />}
105
- <EnableSwitch s={s} />
117
+ {!s.done_at && <EnableSwitch s={s} />}
106
118
  </div>
107
119
  );
108
120
  }
109
121
 
122
+ function Group({
123
+ title,
124
+ rows,
125
+ onOpen,
126
+ }: {
127
+ title: string;
128
+ rows: ScheduleView[];
129
+ onOpen: (id: string) => void;
130
+ }) {
131
+ if (rows.length === 0) return null;
132
+ return (
133
+ <section className="space-y-2">
134
+ <h4 className="text-muted-foreground px-1 text-xs font-medium">
135
+ {title} · {rows.length}
136
+ </h4>
137
+ {rows.map((s) => (
138
+ <ScheduleRow key={s.id} s={s} onOpen={() => onOpen(s.id)} />
139
+ ))}
140
+ </section>
141
+ );
142
+ }
143
+
110
144
  function Detail({
111
145
  id,
112
146
  onEdit,
@@ -134,11 +168,12 @@ function Detail({
134
168
  <div className="space-y-1">
135
169
  <div className="flex items-center gap-2">
136
170
  <h3 className="min-w-0 flex-1 truncate font-semibold">{s.name}</h3>
137
- <EnableSwitch s={s} />
171
+ {!s.done_at && <EnableSwitch s={s} />}
138
172
  </div>
139
173
  <p className="text-muted-foreground text-sm">
140
174
  {KIND_LABEL[s.kind]}
141
175
  {where(s)} · {s.description}
176
+ {s.oneOff && ` · by ${s.createdBy.name}`}
142
177
  </p>
143
178
  <p className="text-muted-foreground text-xs">
144
179
  <NextRun s={s} /> · {s.timezone}
@@ -151,7 +186,7 @@ function Detail({
151
186
  <Button
152
187
  size="sm"
153
188
  className="h-11 md:h-8"
154
- disabled={run.isPending}
189
+ disabled={run.isPending || !!s.done_at}
155
190
  onClick={() => run.mutate(s.id)}
156
191
  >
157
192
  <Play className="h-3.5 w-3.5" />
@@ -174,7 +209,7 @@ function Detail({
174
209
  disabled={archive.isPending}
175
210
  onClick={() => archive.mutate(s.id, { onSuccess: onGone })}
176
211
  >
177
- Remove (keeps history)
212
+ {s.oneOff && !s.done_at ? "Cancel it" : "Remove (keeps history)"}
178
213
  </Button>
179
214
  ) : (
180
215
  <Button
@@ -184,7 +219,7 @@ function Detail({
184
219
  onClick={() => setConfirm(true)}
185
220
  >
186
221
  <Trash2 className="h-3.5 w-3.5" />
187
- Remove
222
+ {s.oneOff && !s.done_at ? "Cancel" : "Remove"}
188
223
  </Button>
189
224
  )}
190
225
  </div>
@@ -300,13 +335,24 @@ export function SchedulesPanel() {
300
335
  </p>
301
336
  </div>
302
337
  )}
303
- {schedules.map((s) => (
304
- <ScheduleRow
305
- key={s.id}
306
- s={s}
307
- onOpen={() => setView({ mode: "detail", id: s.id })}
308
- />
309
- ))}
338
+ {(() => {
339
+ const open = (id: string) => setView({ mode: "detail", id });
340
+ const upcoming = schedules
341
+ .filter((s) => s.oneOff && !s.done_at)
342
+ .sort((a, b) => (a.run_at ?? 0) - (b.run_at ?? 0));
343
+ const done = schedules.filter((s) => s.done_at);
344
+ return (
345
+ <>
346
+ <Group title="Upcoming" rows={upcoming} onOpen={open} />
347
+ <Group
348
+ title="Recurring"
349
+ rows={schedules.filter((s) => !s.oneOff)}
350
+ onOpen={open}
351
+ />
352
+ <Group title="Done" rows={done} onOpen={open} />
353
+ </>
354
+ );
355
+ })()}
310
356
  {!isPending && <PhoneLine />}
311
357
  </div>
312
358
  )}
@@ -97,7 +97,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
97
97
  // Opens Settings > Schedules.
98
98
  menuItem("Schedules", () => schedulesUiActions.open(workspaceId), {
99
99
  icon: Clock,
100
- description: schedules.nextText,
100
+ description: schedules.upcomingText ?? schedules.nextText,
101
101
  badge: schedules.failed > 0 && (
102
102
  <span className="text-destructive flex items-center gap-1.5 text-xs font-medium tabular-nums">
103
103
  <span aria-hidden className={failedDot} />
@@ -3,12 +3,13 @@
3
3
  import { useSchedulesQuery } from "@/data/schedules";
4
4
  import { schedulesBadge } from "@/lib/schedules/badge";
5
5
  import { formatRunTime } from "@/lib/schedules/cron";
6
+ import { relativeTime } from "@/lib/schedules/when";
6
7
 
7
8
  // Schedules at a glance for the header's menu: how many failed their last
8
9
  // run (a red badge) and when the next one runs (a line under the item).
9
10
  export function useSchedulesGlance(workspaceId: string | null) {
10
11
  const { data: schedules = [] } = useSchedulesQuery(workspaceId);
11
- const { next, failed } = schedulesBadge(schedules);
12
+ const { next, failed, upcoming, nextUpcoming } = schedulesBadge(schedules);
12
13
  const failedText = failed
13
14
  ? `${failed} schedule${failed === 1 ? "" : "s"} failed last run`
14
15
  : null;
@@ -17,5 +18,8 @@ export function useSchedulesGlance(workspaceId: string | null) {
17
18
  : schedules.length
18
19
  ? "Nothing scheduled to run"
19
20
  : "No schedules yet";
20
- return { failed, failedText, nextText };
21
+ const upcomingText = upcoming
22
+ ? `${upcoming} upcoming${nextUpcoming ? ` · next ${relativeTime(nextUpcoming.at)}` : ""}`
23
+ : null;
24
+ return { failed, failedText, nextText, upcoming, upcomingText };
21
25
  }
@@ -64,7 +64,8 @@ export function demoConversation(stepMs = 40): ChatConversation {
64
64
  const words = DEMO_REPLY.split(/(?<= )/);
65
65
  let i = 0;
66
66
  for (; i < words.length && !stopped && !closed; i++) {
67
- await sleep(stepMs);
67
+ // A step of 0 (tests) streams with no timer per word.
68
+ if (stepMs) await sleep(stepMs);
68
69
  out.push({ type: "delta", id, text: words[i] });
69
70
  }
70
71
  out.push({
@@ -1,17 +1,63 @@
1
- // For tests of file watching.
1
+ // For tests that wait on async work or stand in for file watching.
2
+
3
+ import fs from "fs";
4
+ import { vi } from "vitest";
2
5
 
3
6
  export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
4
7
 
5
- // Makes a change, and again every 2s until it's seen: macOS can be slow, and
6
- // drops an event while another watcher in the process opens or closes.
7
- // Returns how many times it changed things.
8
- export async function touchUntil(touch: () => void, seen: () => boolean) {
9
- for (let n = 1; n <= 15; n++) {
10
- touch();
11
- for (let t = 0; t < 100; t++) {
12
- if (seen()) return n;
13
- await sleep(20);
14
- }
8
+ // Polls until `check` holds. The ceiling is generous: it only matters when
9
+ // something is really broken, and a loaded machine never reaches it.
10
+ export async function until(check: () => boolean, ceilingMs = 10_000) {
11
+ const end = Date.now() + ceilingMs;
12
+ while (!check()) {
13
+ if (Date.now() > end) throw new Error("condition never held");
14
+ await sleep(10);
15
15
  }
16
- throw new Error("never seen");
16
+ }
17
+
18
+ export interface FakeWatch {
19
+ path: string;
20
+ recursive: boolean;
21
+ closed: boolean;
22
+ // Delivers an event as the OS would (null: no file name given).
23
+ fire(name: string | null): void;
24
+ }
25
+
26
+ // Replaces fs.watch with handles the test fires by hand: the OS's own events
27
+ // arrive when it likes, which under load is never inside a test's wait.
28
+ export function fakeFsWatch() {
29
+ const handles: FakeWatch[] = [];
30
+ const spy = vi.spyOn(fs, "watch").mockImplementation(((
31
+ p: unknown,
32
+ o: unknown,
33
+ listener: (e: string, name: string | null) => void
34
+ ) => {
35
+ const h: FakeWatch = {
36
+ path: String(p),
37
+ recursive: !!(o as { recursive?: boolean } | null)?.recursive,
38
+ closed: false,
39
+ fire: (name) => listener("rename", name),
40
+ };
41
+ handles.push(h);
42
+ return {
43
+ on() {},
44
+ close() {
45
+ h.closed = true;
46
+ },
47
+ };
48
+ }) as unknown as typeof fs.watch);
49
+ return {
50
+ all: handles,
51
+ open: () => handles.filter((h) => !h.closed),
52
+ // The live handle on `p`.
53
+ at(p: string, recursive?: boolean) {
54
+ return handles.find(
55
+ (h) =>
56
+ !h.closed &&
57
+ h.path === p &&
58
+ (recursive === undefined || h.recursive === recursive)
59
+ );
60
+ },
61
+ restore: () => spy.mockRestore(),
62
+ };
17
63
  }
@@ -1445,6 +1445,20 @@ const migrations: Migration[] = [
1445
1445
  );
1446
1446
  },
1447
1447
  },
1448
+ {
1449
+ id: 63,
1450
+ name: "one_off_schedules",
1451
+ up: (db) =>
1452
+ db.transaction(() => {
1453
+ // A one-off has a run time (epoch ms, UTC) and no cron; done_at is
1454
+ // set once its single run settles. Recurring rows leave both NULL.
1455
+ db.exec(`ALTER TABLE schedules ADD COLUMN run_at INTEGER`);
1456
+ db.exec(`ALTER TABLE schedules ADD COLUMN done_at INTEGER`);
1457
+ db.exec(
1458
+ `CREATE INDEX IF NOT EXISTS idx_schedules_run_at ON schedules(run_at) WHERE run_at IS NOT NULL`
1459
+ );
1460
+ })(),
1461
+ },
1448
1462
  ];
1449
1463
 
1450
1464
  // `upTo`: stop after this id (tests that start from an older database).
@@ -107,6 +107,9 @@ Acting:
107
107
  - \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
108
108
  - \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
109
109
  - \`${TOOL_NAMES.rule_remove}\` (id): remove a standing rule you kept (Saad's are his to remove).
110
+ - \`${TOOL_NAMES.remind_me}\` (at, prompt, name?): a one-off reminder to yourself ("tomorrow 10am", "in 2h"); it reaches you once as a scheduled prompt, not an approval, even if AgentOS was off. At least 10 minutes ahead. Prefer it to remembering to check back.
111
+ - \`${TOOL_NAMES.reminders}\` (): your upcoming reminders and the last few that ran.
112
+ - \`${TOOL_NAMES.cancel_reminder}\` (id): cancel a reminder you set.
110
113
  - \`${TOOL_NAMES.review}\` (target, fresh?): review a task's PR, or an external PR (#N, owner/repo#N or URL), at its head commit, or read the stored verdict.
111
114
  - \`${TOOL_NAMES.sign_off}\` (task): merge a task's PR or an external PR through the gates.
112
115
  - \`${TOOL_NAMES.arm}\` (task): merge a task's PR by itself the moment every gate passes; one event when it merges or a gate fails.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * remind_me, reminders and cancel_reminder: the orchestrator's one-off
3
+ * schedules. A reminder is a schedule that messages the workspace's own
4
+ * orchestrator once, so it arrives as a scheduled prompt and never as an
5
+ * approval. Every call is scoped to the caller's workspace, and only reminders
6
+ * an orchestrator made can be cancelled here: Saad's are his to change.
7
+ */
8
+
9
+ import {
10
+ archiveSchedule,
11
+ createSchedule,
12
+ getSessionRow,
13
+ listSchedules,
14
+ type Schedule,
15
+ } from "../schedules/store";
16
+ import { reminderName } from "../schedules";
17
+ import { DEFAULT_TIMEZONE, formatRunTime } from "../schedules/cron";
18
+ import { parseWhen, relativeTime } from "../schedules/when";
19
+ import { getOrchestrator } from "./home";
20
+ import { addNote } from "./notes";
21
+
22
+ const SHOW_DONE = 3;
23
+
24
+ const line = (s: Schedule) =>
25
+ `${s.id.slice(0, 8)} ${s.name}: ${formatRunTime(s.run_at ?? 0, s.timezone)} (${relativeTime(s.run_at ?? 0)})`;
26
+
27
+ const madeByOrchestrator = (s: Schedule) =>
28
+ getSessionRow(s.created_by_session_id ?? "")?.role === "orchestrator";
29
+
30
+ export function remindMe(
31
+ w: string,
32
+ at: string,
33
+ prompt: string,
34
+ name?: string
35
+ ): string {
36
+ const me = getOrchestrator(w);
37
+ if (!me) throw new Error("This workspace has no orchestrator");
38
+ const runAt = parseWhen(at, DEFAULT_TIMEZONE);
39
+ const schedule = createSchedule({
40
+ workspaceId: w,
41
+ name: name?.trim() || reminderName(w, runAt, DEFAULT_TIMEZONE),
42
+ at: runAt,
43
+ timezone: DEFAULT_TIMEZONE,
44
+ prompt,
45
+ kind: "orchestrator",
46
+ createdBySessionId: me.id,
47
+ });
48
+ addNote(
49
+ w,
50
+ `Reminder set: ${schedule.name}, ${line(schedule).split(": ")[1]}`
51
+ );
52
+ return `Reminder ${schedule.id.slice(0, 8)} set for ${formatRunTime(runAt, DEFAULT_TIMEZONE)} (${relativeTime(runAt)}). It reaches you as a scheduled prompt, not an approval.`;
53
+ }
54
+
55
+ export function listReminders(w: string): string {
56
+ const all = listSchedules(w).filter((s) => s.run_at != null);
57
+ const upcoming = all
58
+ .filter((s) => s.enabled && !s.done_at)
59
+ .sort((a, b) => (a.run_at ?? 0) - (b.run_at ?? 0));
60
+ const done = all
61
+ .filter((s) => s.done_at)
62
+ .sort((a, b) => (b.done_at ?? 0) - (a.done_at ?? 0))
63
+ .slice(0, SHOW_DONE);
64
+ if (!upcoming.length && !done.length) return "No reminders.";
65
+ return [
66
+ upcoming.length
67
+ ? `Upcoming:\n${upcoming.map(line).join("\n")}`
68
+ : "None upcoming.",
69
+ done.length
70
+ ? `Recently done:\n${done.map((s) => `${s.id.slice(0, 8)} ${s.name}`).join("\n")}`
71
+ : "",
72
+ ]
73
+ .filter(Boolean)
74
+ .join("\n\n");
75
+ }
76
+
77
+ export function cancelReminder(w: string, id: string): string {
78
+ const ref = id.trim();
79
+ const matches = listSchedules(w).filter(
80
+ (s) =>
81
+ s.run_at != null &&
82
+ !s.done_at &&
83
+ (s.id === ref || (ref.length >= 6 && s.id.startsWith(ref)))
84
+ );
85
+ // Another workspace's, a done one and one Saad made all read the same.
86
+ if (matches.length !== 1 || !madeByOrchestrator(matches[0]))
87
+ throw new Error(`No reminder "${ref}" in this workspace`);
88
+ archiveSchedule(matches[0].id);
89
+ addNote(w, `Reminder cancelled: ${matches[0].name}`);
90
+ return `Reminder ${matches[0].id.slice(0, 8)} cancelled.`;
91
+ }