@saadnvd1/agent-os 0.3.477 → 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.
Files changed (65) hide show
  1. package/README.md +26 -1
  2. package/app/api/schedules/route.ts +1 -0
  3. package/app/api/sessions/[id]/route.test.ts +66 -0
  4. package/app/api/sessions/[id]/route.ts +10 -7
  5. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  6. package/app/api/tmux/kill-all/route.ts +2 -1
  7. package/components/Orchestrator/OrchestratorBar.tsx +12 -0
  8. package/components/Schedules/ScheduleForm.tsx +94 -12
  9. package/components/Schedules/SchedulesPanel.tsx +60 -14
  10. package/components/SessionList/OrchestratorPinRow.tsx +12 -6
  11. package/components/SessionList/QuickChangeRow.tsx +58 -0
  12. package/components/SessionList/SessionRow.tsx +4 -0
  13. package/components/SessionList/SidebarHeader.tsx +1 -1
  14. package/components/SessionList/useSchedulesGlance.ts +6 -2
  15. package/components/Tasks/TaskRow.tsx +2 -17
  16. package/components/Tasks/task-state-labels.ts +18 -0
  17. package/lib/away/sweep.ts +2 -0
  18. package/lib/chat/drivers/demo.ts +2 -1
  19. package/lib/chat/hold.ts +6 -2
  20. package/lib/chat/runner.ts +5 -0
  21. package/lib/chat/test-touch.ts +58 -12
  22. package/lib/chat/worker/client.ts +2 -3
  23. package/lib/db/migrations.ts +31 -0
  24. package/lib/db/types.ts +3 -0
  25. package/lib/done/index.ts +2 -8
  26. package/lib/orchestration.ts +4 -5
  27. package/lib/orchestrator/act.ts +3 -6
  28. package/lib/orchestrator/brief.ts +8 -3
  29. package/lib/orchestrator/conditions.ts +2 -1
  30. package/lib/orchestrator/describe.ts +1 -1
  31. package/lib/orchestrator/facts.ts +13 -0
  32. package/lib/orchestrator/git-guard.ts +201 -0
  33. package/lib/orchestrator/pr-wake.ts +9 -1
  34. package/lib/orchestrator/quick-store.ts +18 -0
  35. package/lib/orchestrator/quick.ts +307 -0
  36. package/lib/orchestrator/rebase.ts +11 -1
  37. package/lib/orchestrator/reminders.ts +91 -0
  38. package/lib/orchestrator/review.ts +3 -0
  39. package/lib/orchestrator/serve.ts +26 -0
  40. package/lib/orchestrator/signoff.ts +28 -6
  41. package/lib/orchestrator/task-state.ts +2 -0
  42. package/lib/orchestrator/tool-names.ts +12 -2
  43. package/lib/orchestrator/tool-schemas.ts +43 -0
  44. package/lib/orchestrator/tools.ts +10 -0
  45. package/lib/orchestrator/write-guard.ts +80 -3
  46. package/lib/schedules/badge.ts +11 -1
  47. package/lib/schedules/cron.ts +1 -1
  48. package/lib/schedules/index.ts +67 -6
  49. package/lib/schedules/run.ts +23 -6
  50. package/lib/schedules/scheduler.ts +12 -2
  51. package/lib/schedules/start.ts +11 -5
  52. package/lib/schedules/store.ts +133 -16
  53. package/lib/schedules/when.ts +166 -0
  54. package/lib/sessions/end.ts +15 -0
  55. package/lib/sidebar/shelves.ts +19 -1
  56. package/lib/tasks/finish.ts +2 -8
  57. package/lib/tasks/index.ts +18 -6
  58. package/lib/tasks/move-guard.ts +2 -0
  59. package/lib/tasks/move.ts +2 -6
  60. package/lib/tasks/queue.ts +3 -2
  61. package/lib/tasks/start.ts +2 -4
  62. package/lib/tmux/end.ts +33 -0
  63. package/lib/tmux/orphans.ts +146 -0
  64. package/package.json +1 -1
  65. package/server.ts +9 -0
@@ -26,6 +26,9 @@ import { readSession } from "./read";
26
26
  import { reviewTarget, signOffTarget } from "./external-gates";
27
27
  import { land } from "./signoff";
28
28
  import { arm, disarm } from "./armed";
29
+ import { quickChange, quickPR } from "./quick";
30
+ import { review } from "./review";
31
+ import { cancelReminder, listReminders, remindMe } from "./reminders";
29
32
  import { parseArgs as p, type ToolName } from "./tool-schemas";
30
33
 
31
34
  export { isToolName, TOOLS, type ToolName } from "./tool-schemas";
@@ -37,6 +40,8 @@ const ACTING = new Set<ToolName>([
37
40
  "start_task",
38
41
  "set_task_model",
39
42
  "start_session",
43
+ "quick_change",
44
+ "quick_pr",
40
45
  "stack",
41
46
  "land",
42
47
  "drop",
@@ -119,6 +124,19 @@ export async function runTool(
119
124
  view: a.view,
120
125
  });
121
126
  }
127
+ case "quick_change": {
128
+ const a = p(tool, raw);
129
+ return quickChange(w, a.project, a.name);
130
+ }
131
+ case "quick_pr": {
132
+ const a = p(tool, raw);
133
+ const opened = await quickPR(w, a.change, a.title, a.body);
134
+ // Its code-review gate is this review: it starts at once.
135
+ const reviewing = await review(w, opened.id).catch(
136
+ (e: Error) => `Its review couldn't start (${e.message}): call review.`
137
+ );
138
+ return `${opened.text}\n${reviewing}`;
139
+ }
122
140
  case "stack": {
123
141
  const a = p(tool, raw);
124
142
  return stack(w, a.target, a.plan_only);
@@ -152,6 +170,14 @@ export async function runTool(
152
170
  addNote(w, `Removed standing rule ${rule.id}: ${rule.text}`);
153
171
  return `Rule ${rule.id} removed.`;
154
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);
155
181
  case "review": {
156
182
  const a = p(tool, raw);
157
183
  return reviewTarget(w, a.target, { fresh: a.fresh });
@@ -15,6 +15,7 @@ import { getStack, landInBackground } from "../stacks";
15
15
  import { LAND_DEFAULTS } from "../stacks/land";
16
16
  import { db, stackQueries, type Session } from "../db";
17
17
  import { getCheck } from "./checks";
18
+ import { isQuick, quickCodeReview } from "./quick";
18
19
  import {
19
20
  addedLines,
20
21
  changedFiles,
@@ -82,6 +83,22 @@ export async function judge(
82
83
  };
83
84
  const sha = pr.head;
84
85
  const repo = repoOf(task);
86
+ // A PR quick_pr opened (by the row AgentOS wrote, never the body): its
87
+ // code review is the independent review of this head, and until that
88
+ // passes it waits, before any gate is judged or counted.
89
+ const quick = isQuick(task)
90
+ ? quickCodeReview(
91
+ sha,
92
+ getCheck(task.id, sha, "review"),
93
+ restackedOf(task)?.to === sha
94
+ )
95
+ : null;
96
+ if (quick?.wait)
97
+ return {
98
+ ok: false,
99
+ wait: true,
100
+ text: `${task.name} (PR #${pr.number} at ${short(sha)}) can't merge yet: ${quick.wait}`,
101
+ };
85
102
  return gateVerdict(
86
103
  workspaceId,
87
104
  { id: task.id, name: task.name, repo, refs: task, pr, sha },
@@ -100,12 +117,17 @@ export async function judge(
100
117
  fromCard: !!task.lh_card_id,
101
118
  scope: getCheck(task.id, sha, "scope"),
102
119
  stackRefusal: landing ? null : signOffRefusal(task.id),
103
- codeReview: codeReviewGate({
104
- head: sha,
105
- attribution: pr.attribution,
106
- review: (at) => getCheck(task.id, at, "review"),
107
- restacked: restackedOf(task),
108
- }),
120
+ codeReview: (() => {
121
+ const gate = codeReviewGate({
122
+ head: sha,
123
+ attribution: pr.attribution,
124
+ review: (at) => getCheck(task.id, at, "review"),
125
+ restacked: restackedOf(task),
126
+ });
127
+ // A quick change keeps the body checks (attribution, unreadable);
128
+ // its wait on the review was judged above.
129
+ return quick ? { refusal: gate.refusal, wait: null } : gate;
130
+ })(),
109
131
  }),
110
132
  opts
111
133
  );
@@ -17,6 +17,8 @@ import { untrusted } from "./untrusted";
17
17
  export async function waitingState(
18
18
  task: Session
19
19
  ): Promise<{ blocked: string | null; waitingOn: string | null }> {
20
+ // A quick change has no agent to block or ask: the orchestrator makes it.
21
+ if (task.quick_of) return { blocked: null, waitingOn: null };
20
22
  // A linked machine's chat lives in that machine's store, not this one's:
21
23
  // its own reading of the task is asked for, fresh.
22
24
  const link = hostLink(task.host_id);
@@ -16,6 +16,8 @@ export const TOOL_NAMES = {
16
16
  start_task: name("start_task"),
17
17
  set_task_model: name("set_task_model"),
18
18
  start_session: name("start_session"),
19
+ quick_change: name("quick_change"),
20
+ quick_pr: name("quick_pr"),
19
21
  stack: name("stack"),
20
22
  stack_status: name("stack_status"),
21
23
  land: name("land"),
@@ -30,6 +32,9 @@ export const TOOL_NAMES = {
30
32
  sign_off: name("sign_off"),
31
33
  arm: name("arm"),
32
34
  disarm: name("disarm"),
35
+ remind_me: name("remind_me"),
36
+ reminders: name("reminders"),
37
+ cancel_reminder: name("cancel_reminder"),
33
38
  ask_saad: name("ask_saad"),
34
39
  } as const;
35
40
 
@@ -39,8 +44,11 @@ export const TOOL_NAMES = {
39
44
  // braked; the shell keeps only `aos` commands that read, and notify, which
40
45
  // reaches only Saad, so `aos send`, `task`, `spawn` and `stack` can't go
41
46
  // around them. It writes files, but never in a project repository, a task
42
- // worktree, AgentOS's own state or a secret file: its worker checks each
43
- // write first (write-guard.ts).
47
+ // worktree, AgentOS's own state or a secret file, except inside its own
48
+ // quick changes' worktrees (quick.ts), where it may also run git that
49
+ // looks, stages or undoes, never commits with hooks or pushes: its worker
50
+ // checks each write and each shell command first (write-guard.ts,
51
+ // git-guard.ts).
44
52
  export const ORCHESTRATOR_PERMISSIONS: {
45
53
  permissionMode: PermissionMode;
46
54
  allowedTools: string[];
@@ -67,6 +75,8 @@ export const ORCHESTRATOR_PERMISSIONS: {
67
75
  "Bash(aos notify:*)",
68
76
  "Bash(aos docs:*)",
69
77
  "Bash(aos doc:*)",
78
+ // Only `git -C <its own quick worktree>`, refused anywhere else.
79
+ "Bash(git:*)",
70
80
  ],
71
81
  disallowedTools: [],
72
82
  };
@@ -135,6 +135,32 @@ export const TOOL_SHAPES = {
135
135
  .describe("A short name, 2-6 words (default: one made from the prompt)"),
136
136
  view,
137
137
  },
138
+ quick_change: {
139
+ project,
140
+ name: z
141
+ .string()
142
+ .trim()
143
+ .min(1)
144
+ .max(80)
145
+ .describe("A short name for the change, 2-6 words"),
146
+ },
147
+ quick_pr: {
148
+ change: ref("Your quick change, by name or id"),
149
+ title: z
150
+ .string()
151
+ .trim()
152
+ .min(1)
153
+ .max(200)
154
+ .describe("The PR's title, also its commit message"),
155
+ body: z
156
+ .string()
157
+ .trim()
158
+ .min(1)
159
+ .max(20000)
160
+ .describe(
161
+ "What changed and why; the review judges the change against it"
162
+ ),
163
+ },
138
164
  stack: {
139
165
  target: ref("Board or project name"),
140
166
  plan_only: z.boolean().optional(),
@@ -168,6 +194,23 @@ export const TOOL_SHAPES = {
168
194
  .describe("The rule, one sentence"),
169
195
  },
170
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") },
171
214
  review: {
172
215
  target: ref("The task, or its PR (#12 or URL)"),
173
216
  fresh: z
@@ -66,6 +66,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
66
66
  'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
67
67
  start_session:
68
68
  "Start an interactive agent session in a project with a prompt. It runs as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.",
69
+ quick_change:
70
+ "Make a small, low-risk edit yourself (a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. You may write there (Write, Edit) and run git there as `git -C <path> ...` (status, diff, add, rm, mv, restore, reset, log, show); nowhere else in a project. Anything needing real work, tests or an investigation is a task (start_task).",
71
+ quick_pr:
72
+ "Commit everything in a quick change's worktree, push its branch (never the default branch) and open its PR, or push to the PR it already has. The PR goes through the same gates as a task's; with no task agent to run /do-code-review, its code-review gate is the independent review of its exact head, which starts now.",
69
73
  stack:
70
74
  "Run a LumifyHub board's open cards as stacked tasks (each start braked), or with plan_only just show the plan.",
71
75
  stack_status: "One stack's items, PRs and progress.",
@@ -80,6 +84,12 @@ const DESCRIPTIONS: Record<ToolName, string> = {
80
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.',
81
85
  rule_remove:
82
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.",
83
93
  review:
84
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.",
85
95
  sign_off:
@@ -21,6 +21,8 @@ import { isSecretPath } from "../secret-paths";
21
21
  import { WORKTREES_DIR } from "../worktrees";
22
22
  import { orchestratorDir } from "./home";
23
23
  import { LIMITS_FILE } from "./usage";
24
+ import { GIT_LAYOUT_FILE, readCommand } from "./git-guard";
25
+ import { quickWorktrees } from "./quick-store";
24
26
 
25
27
  // The tools that write a file, and the argument naming it.
26
28
  export const WRITE_TOOLS: Record<string, string> = {
@@ -43,6 +45,9 @@ export interface WriteBounds {
43
45
  home: string;
44
46
  // Folders whose files run (~/Library, PATH, the usage brake's input...).
45
47
  runs: string[];
48
+ // Its own quick changes' worktrees (quick.ts): the one place in a
49
+ // project it may write, everything else in `repos` still refused.
50
+ quick?: string[];
46
51
  }
47
52
 
48
53
  // Wherever they are, files that configure what an agent, git or a
@@ -129,6 +134,7 @@ export async function writeRefusal(
129
134
  let ownDir: string[];
130
135
  let home: string[];
131
136
  let runs: string[];
137
+ let quick: string[][];
132
138
  try {
133
139
  // Where it lands as written, and with `..` taken by name first, as a
134
140
  // tool that normalises the path before opening it would; and where it
@@ -151,16 +157,27 @@ export async function writeRefusal(
151
157
  state = await both(bounds.state);
152
158
  ownDir = await both(bounds.ownDir);
153
159
  home = await both(bounds.home);
160
+ quick = await Promise.all((bounds.quick ?? []).map(both));
154
161
  } catch (e) {
155
162
  return `Couldn't resolve ${filePath}: ${(e as Error).message}.`;
156
163
  }
157
164
  for (const target of targets) {
158
- const repo = repos.find((r) => within(target, r));
165
+ // Inside one of its quick worktrees, the roots that hold that worktree
166
+ // (the worktrees folder, a project above it) don't count; any other
167
+ // root, one nested inside it included, still refuses.
168
+ const ownQuick = quick.find((q) => q.some((d) => within(target, d)));
169
+ const holdsQuick = (r: string) =>
170
+ !!ownQuick && ownQuick.some((d) => within(d, r));
171
+ const repo = repos.find((r) => within(target, r) && !holdsQuick(r));
159
172
  if (repo)
160
- return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), so its PR goes through the merge gates.`;
173
+ return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), or for a small edit, in a quick change's worktree (quick_change), so its PR goes through the merge gates.`;
174
+ if (ownQuick && GIT_LAYOUT_FILE.test(target))
175
+ return `${filePath} is part of a git repository's layout (HEAD, .git/, .gitattributes); git config can run programs, so orchestrators never write those.`;
161
176
  if (AGENTOS_TEMP.test(target))
162
177
  return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
163
- const own = ownDir.some((d) => within(target, d));
178
+ // Its folder, or a quick worktree (which sits in ~/.agent-os/worktrees):
179
+ // AgentOS's state and the home's dot-folders hold it, but it's its own.
180
+ const own = ownDir.some((d) => within(target, d)) || !!ownQuick;
164
181
  if (state.some((d) => within(target, d)) && !own)
165
182
  return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
166
183
  if (isSecretPath(target) || isSecretPath(filePath))
@@ -219,6 +236,7 @@ export async function orchestratorWriteBounds(
219
236
  ...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
220
237
  ],
221
238
  ownDir: orchestratorDir(session.workspace_id ?? ""),
239
+ quick: quickWorktrees(session.id),
222
240
  state: path.join(os.homedir(), ".agent-os"),
223
241
  repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
224
242
  };
@@ -287,9 +305,68 @@ export async function orchestratorToolRefusal(
287
305
  tool: string,
288
306
  input: Record<string, unknown>
289
307
  ): Promise<string | null> {
308
+ if (tool === "Bash") return commandRefusal(session, input.command);
290
309
  const arg = WRITE_TOOLS[tool];
291
310
  if (!arg) return null;
292
311
  const filePath = input[arg];
293
312
  if (typeof filePath !== "string") return "No file path to check.";
294
313
  return writeRefusal(filePath, await orchestratorWriteBounds(session));
295
314
  }
315
+
316
+ // A shell command: one plain command, and git only in its own quick
317
+ // worktrees (git-guard.ts), the folder it names resolved like a write's.
318
+ async function commandRefusal(
319
+ session: Session,
320
+ command: unknown
321
+ ): Promise<string | null> {
322
+ const read = readCommand(command);
323
+ if (read.refusal !== null || read.dir === null) return read.refusal;
324
+ const quick = quickWorktrees(session.id);
325
+ const dir = read.dir;
326
+ if (!dir.startsWith("/") || !quick.length)
327
+ return `Refused: git runs only in one of your quick changes' worktrees, named by its full path (${quick.length ? quick.join(", ") : "you have none open; start one with quick_change"}).`;
328
+ return (await isQuickRoot(dir, quick))
329
+ ? null
330
+ : `Refused: ${read.dir} isn't the root of one of your quick changes' worktrees (${quick.join(", ")}). Git runs at the root only, never in a subfolder, a project checkout or another session's worktree.`;
331
+ }
332
+
333
+ // Whether a folder is inside one of these worktrees by every way the
334
+ // system could read it: as named, with `..` taken by name, and with links
335
+ // followed.
336
+ export async function insideQuick(
337
+ dir: string,
338
+ quick: string[]
339
+ ): Promise<boolean> {
340
+ try {
341
+ const forms = [
342
+ path.resolve(dir),
343
+ await physicalPath(dir),
344
+ await physicalPath(path.resolve(dir)),
345
+ ];
346
+ const roots = (
347
+ await Promise.all(
348
+ quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
349
+ )
350
+ ).flat();
351
+ return forms.every((f) => roots.some((r) => within(f, r)));
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+
357
+ // Whether a folder is exactly the root of one of these worktrees (as named,
358
+ // and with links followed), where git finds the real git dir.
359
+ async function isQuickRoot(dir: string, quick: string[]): Promise<boolean> {
360
+ if (!(await insideQuick(dir, quick))) return false;
361
+ try {
362
+ const forms = [path.resolve(dir), await physicalPath(dir)];
363
+ const roots = (
364
+ await Promise.all(
365
+ quick.map(async (q) => [path.resolve(q), await physicalPath(q)])
366
+ )
367
+ ).flat();
368
+ return forms.every((f) => roots.includes(f));
369
+ } catch {
370
+ return false;
371
+ }
372
+ }
@@ -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),