@saadnvd1/agent-os 0.3.532 → 0.3.534

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 (93) hide show
  1. package/README.md +71 -3
  2. package/app/api/devices/connect/route.ts +79 -0
  3. package/app/api/features/pieces/[id]/release/route.ts +16 -0
  4. package/app/api/features/route.ts +8 -0
  5. package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
  6. package/app/api/recipes/[id]/preview/route.ts +19 -0
  7. package/app/api/recipes/[id]/route.ts +51 -0
  8. package/app/api/recipes/[id]/run/route.ts +35 -0
  9. package/app/api/recipes/[id]/runs/route.ts +23 -0
  10. package/app/api/recipes/draft/route.ts +14 -0
  11. package/app/api/recipes/route.ts +35 -0
  12. package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
  13. package/components/CommandPalette/useAppCommands.ts +20 -0
  14. package/components/Devices/ConnectCard.tsx +2 -15
  15. package/components/Features/FeaturesSection.tsx +100 -0
  16. package/components/Features/index.ts +1 -0
  17. package/components/Recipes/RecipeForm.tsx +251 -0
  18. package/components/Recipes/RecipePage.tsx +127 -0
  19. package/components/Recipes/RecipesPanel.tsx +225 -0
  20. package/components/Recipes/RunChip.tsx +16 -0
  21. package/components/Recipes/RunHistory.tsx +88 -0
  22. package/components/Recipes/RunSheet.tsx +84 -0
  23. package/components/Recipes/TimerFields.tsx +193 -0
  24. package/components/Recipes/format.ts +51 -0
  25. package/components/Recipes/index.ts +1 -0
  26. package/components/Schedules/SchedulesPanel.tsx +1 -0
  27. package/components/SessionList/RowSubtitle.tsx +16 -0
  28. package/components/SessionList/SessionRowMenu.tsx +29 -0
  29. package/components/SessionList/SidebarHeader.tsx +5 -0
  30. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  31. package/components/Settings/SettingsDialog.tsx +9 -0
  32. package/components/Tasks/PlanReview.tsx +253 -0
  33. package/components/Tasks/TaskRow.tsx +19 -44
  34. package/components/Tasks/TasksDialog.tsx +2 -0
  35. package/components/Welcome/Block.tsx +26 -0
  36. package/components/Welcome/ConnectBlock.tsx +169 -0
  37. package/components/Welcome/DevicesStep.tsx +4 -26
  38. package/data/devices/queries.ts +21 -1
  39. package/data/features/index.ts +40 -0
  40. package/data/push/topics.ts +8 -0
  41. package/data/recipes/index.ts +129 -0
  42. package/data/tasks/index.ts +14 -0
  43. package/lib/away/run.ts +4 -1
  44. package/lib/connect/cli.ts +6 -18
  45. package/lib/connect/link.ts +35 -0
  46. package/lib/connect/serve.ts +12 -1
  47. package/lib/connect/view.ts +28 -0
  48. package/lib/db/changes.ts +8 -0
  49. package/lib/db/migrations.ts +127 -0
  50. package/lib/db/queries.ts +4 -1
  51. package/lib/db/types.ts +6 -0
  52. package/lib/models/plan-step.ts +67 -6
  53. package/lib/notify/copy.ts +28 -10
  54. package/lib/notify/push/events.ts +36 -2
  55. package/lib/notify/push/policy.ts +2 -0
  56. package/lib/notify/push-types.ts +7 -3
  57. package/lib/orchestrator/brief.ts +13 -0
  58. package/lib/orchestrator/conditions.ts +3 -1
  59. package/lib/orchestrator/deliver.ts +2 -0
  60. package/lib/orchestrator/digest.ts +27 -1
  61. package/lib/orchestrator/plan-pieces.ts +41 -0
  62. package/lib/orchestrator/recipe-tools.ts +159 -0
  63. package/lib/orchestrator/review.ts +2 -1
  64. package/lib/orchestrator/serve.ts +25 -0
  65. package/lib/orchestrator/tool-names.ts +5 -0
  66. package/lib/orchestrator/tool-schemas.ts +54 -0
  67. package/lib/orchestrator/tools.ts +10 -0
  68. package/lib/plans/features.ts +200 -0
  69. package/lib/plans/pieces.ts +366 -0
  70. package/lib/plans/release.ts +323 -0
  71. package/lib/plans/view.ts +85 -0
  72. package/lib/recipes/api.ts +19 -0
  73. package/lib/recipes/chip.ts +40 -0
  74. package/lib/recipes/compose.ts +78 -0
  75. package/lib/recipes/draft.ts +93 -0
  76. package/lib/recipes/index.ts +54 -0
  77. package/lib/recipes/result.ts +59 -0
  78. package/lib/recipes/run.ts +202 -0
  79. package/lib/recipes/settle.ts +465 -0
  80. package/lib/recipes/store.ts +378 -0
  81. package/lib/recipes/test-fixtures.ts +49 -0
  82. package/lib/recipes/timer.ts +107 -0
  83. package/lib/recipes/types.ts +116 -0
  84. package/lib/schedules/index.ts +4 -1
  85. package/lib/schedules/start.ts +33 -9
  86. package/lib/schedules/store.ts +6 -2
  87. package/lib/security/demo.ts +2 -0
  88. package/lib/settings-url.ts +1 -0
  89. package/lib/tasks/loop-types.ts +23 -0
  90. package/lib/tasks/plan-view.ts +38 -1
  91. package/package.json +1 -1
  92. package/server.ts +2 -0
  93. package/stores/recipesUi.ts +53 -0
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { ArrowUpRight } from "lucide-react";
4
+ import type { RecipeRun, RecipeTotals } from "@/lib/recipes/types";
5
+ import { formatRunTime } from "@/lib/schedules/cron";
6
+ import { settingsUiActions } from "@/stores/settingsUi";
7
+ import { sessionOpenActions } from "@/stores/sessionOpen";
8
+ import { RunChip } from "./RunChip";
9
+ import { duration, money, sqlMs, triggerLabel } from "./format";
10
+
11
+ export function Totals({ totals }: { totals: RecipeTotals }) {
12
+ if (!totals.runs) return null;
13
+ const bits = [
14
+ `${totals.runs} ${totals.runs === 1 ? "run" : "runs"}`,
15
+ totals.failed ? `${totals.failed} failed` : null,
16
+ totals.costUsd ? money(totals.costUsd) : null,
17
+ totals.avgMs ? `avg ${duration(totals.avgMs)}` : null,
18
+ ].filter(Boolean);
19
+ return <p className="text-muted-foreground text-xs">{bits.join(" · ")}</p>;
20
+ }
21
+
22
+ function Facts({ run }: { run: RecipeRun }) {
23
+ const outputs = run.outputs
24
+ ? Object.entries(run.outputs)
25
+ .map(([k, v]) => `${k} ${v}`)
26
+ .join(" · ")
27
+ : "";
28
+ const text = [outputs, run.summary ?? run.error].filter(Boolean).join(". ");
29
+ return text ? (
30
+ <p className="text-muted-foreground line-clamp-2 text-xs">{text}</p>
31
+ ) : null;
32
+ }
33
+
34
+ export function RunHistory({ runs }: { runs: RecipeRun[] }) {
35
+ return (
36
+ <div className="space-y-2">
37
+ <p className="label-mono text-muted-foreground">History</p>
38
+ {runs.length === 0 && (
39
+ <p className="text-muted-foreground text-sm">No runs yet.</p>
40
+ )}
41
+ <ul className="divide-border/60 divide-y">
42
+ {runs.map((r) => {
43
+ const stats = [duration(r.duration_ms), money(r.cost_usd)]
44
+ .filter(Boolean)
45
+ .join(" · ");
46
+ return (
47
+ <li key={r.id} className="flex items-start gap-3 py-2.5">
48
+ <RunChip status={r.status} />
49
+ <div className="min-w-0 flex-1 space-y-0.5">
50
+ <p className="text-sm tabular-nums">
51
+ #{r.seq} · {formatRunTime(sqlMs(r.created_at))}
52
+ </p>
53
+ <p className="text-muted-foreground truncate text-xs">
54
+ {[
55
+ triggerLabel(r),
56
+ r.pr_number ? `PR #${r.pr_number}` : null,
57
+ stats,
58
+ ]
59
+ .filter(Boolean)
60
+ .join(" · ")}
61
+ </p>
62
+ <Facts run={r} />
63
+ {r.note && (
64
+ <p className="text-muted-foreground truncate text-xs italic">
65
+ Note: {r.note}
66
+ </p>
67
+ )}
68
+ </div>
69
+ {r.session_id && (
70
+ <button
71
+ type="button"
72
+ onClick={() => {
73
+ sessionOpenActions.request(r.session_id!);
74
+ settingsUiActions.close();
75
+ }}
76
+ className="text-primary -mr-2 flex min-h-11 shrink-0 items-center gap-1 px-2 text-xs font-medium"
77
+ >
78
+ Open
79
+ <ArrowUpRight className="h-3.5 w-3.5" />
80
+ </button>
81
+ )}
82
+ </li>
83
+ );
84
+ })}
85
+ </ul>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { Play } from "lucide-react";
5
+ import { uuid } from "@/lib/uuid";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Textarea } from "@/components/ui/textarea";
8
+ import { useRunRecipe, usePreviewQuery } from "@/data/recipes";
9
+ import { MAX_NOTE, type RecipeView } from "@/lib/recipes/types";
10
+
11
+ // Run now: an optional one-off note, and what will be sent. The request id
12
+ // is made once per sheet, so a double tap starts one run.
13
+ export function RunSheet({
14
+ recipe,
15
+ onClose,
16
+ }: {
17
+ recipe: RecipeView;
18
+ onClose: () => void;
19
+ }) {
20
+ const [note, setNote] = useState("");
21
+ const [showPrompt, setShowPrompt] = useState(false);
22
+ const requestId = useRef(uuid());
23
+ const run = useRunRecipe();
24
+ const preview = usePreviewQuery(recipe.id, note.trim(), showPrompt);
25
+ const go = () =>
26
+ run.mutate(
27
+ {
28
+ id: recipe.id,
29
+ note: note.trim() || undefined,
30
+ requestId: requestId.current,
31
+ },
32
+ { onSuccess: onClose }
33
+ );
34
+ return (
35
+ <div className="border-border/60 bg-foreground/[0.03] space-y-3 rounded-xl border p-3">
36
+ <label className="text-sm font-medium" htmlFor="run-note">
37
+ Note for this run (optional)
38
+ </label>
39
+ <Textarea
40
+ id="run-note"
41
+ value={note}
42
+ maxLength={MAX_NOTE}
43
+ rows={2}
44
+ placeholder="Includes #87"
45
+ onChange={(e) => setNote(e.target.value)}
46
+ />
47
+ <button
48
+ type="button"
49
+ aria-expanded={showPrompt}
50
+ className="text-primary min-h-11 text-xs font-medium md:min-h-8"
51
+ onClick={() => setShowPrompt((v) => !v)}
52
+ >
53
+ {showPrompt ? "Hide what it sends" : "Show what it sends"}
54
+ </button>
55
+ {showPrompt && (
56
+ <pre className="bg-background max-h-64 overflow-auto rounded-lg p-3 text-xs whitespace-pre-wrap">
57
+ {preview.data?.prompt ?? (preview.error?.message || "Loading...")}
58
+ </pre>
59
+ )}
60
+ {run.error && (
61
+ <p className="text-destructive text-sm">{run.error.message}</p>
62
+ )}
63
+ <div className="flex gap-2">
64
+ <Button
65
+ size="sm"
66
+ className="h-11 md:h-8"
67
+ disabled={run.isPending || !!recipe.liveRun}
68
+ onClick={go}
69
+ >
70
+ <Play className="h-3.5 w-3.5" />
71
+ {run.isPending ? "Starting..." : "Run"}
72
+ </Button>
73
+ <Button
74
+ size="sm"
75
+ variant="ghost"
76
+ className="h-11 md:h-8"
77
+ onClick={onClose}
78
+ >
79
+ Cancel
80
+ </Button>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { Input } from "@/components/ui/input";
5
+ import { cn } from "@/lib/utils";
6
+ import {
7
+ clock12,
8
+ cronError,
9
+ cronPreset,
10
+ DEFAULT_TIMEZONE,
11
+ describeCron,
12
+ formatRunTime,
13
+ isTimezone,
14
+ MIN_GAP_MINUTES,
15
+ minGapMinutes,
16
+ nextRuns,
17
+ presetCron,
18
+ type PresetKind,
19
+ } from "@/lib/schedules/cron";
20
+ import type { RecipeTimer } from "@/lib/recipes/types";
21
+
22
+ type Mode = "off" | Exclude<PresetKind, "every"> | "custom";
23
+
24
+ const MODES: { value: Mode; label: string }[] = [
25
+ { value: "off", label: "No timer" },
26
+ { value: "hourly", label: "Hourly" },
27
+ { value: "daily", label: "Daily" },
28
+ { value: "weekdays", label: "Weekdays" },
29
+ { value: "weekly", label: "Weekly" },
30
+ { value: "custom", label: "Custom" },
31
+ ];
32
+ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
33
+ const select =
34
+ "border-input bg-background min-h-11 rounded-md border px-2 text-sm md:min-h-9";
35
+
36
+ export interface TimerValue {
37
+ mode: Mode;
38
+ hour: number;
39
+ minute: number;
40
+ weekday: number;
41
+ custom: string;
42
+ timezone: string;
43
+ }
44
+
45
+ export function timerValue(timer: RecipeTimer | null): TimerValue {
46
+ const preset = timer ? cronPreset(timer.cron) : null;
47
+ const mode: Mode = !timer
48
+ ? "off"
49
+ : preset && preset.kind !== "every"
50
+ ? preset.kind
51
+ : "custom";
52
+ return {
53
+ mode,
54
+ hour: preset?.hour ?? 9,
55
+ minute: preset?.minute ?? 0,
56
+ weekday: preset?.weekday ?? 1,
57
+ custom: timer?.cron ?? "0 9 * * 1-5",
58
+ timezone: timer?.timezone ?? DEFAULT_TIMEZONE,
59
+ };
60
+ }
61
+
62
+ export function timerCron(v: TimerValue): string | null {
63
+ if (v.mode === "off") return null;
64
+ if (v.mode === "custom") return v.custom.trim();
65
+ return presetCron({
66
+ kind: v.mode,
67
+ hour: v.hour,
68
+ minute: v.minute,
69
+ weekday: v.weekday,
70
+ });
71
+ }
72
+
73
+ // Why the timer can't be saved, or null.
74
+ export function timerProblem(v: TimerValue): string | null {
75
+ const cron = timerCron(v);
76
+ if (!cron) return null;
77
+ if (!isTimezone(v.timezone)) return "Unknown time zone";
78
+ const bad = cronError(cron);
79
+ if (bad) return bad;
80
+ return minGapMinutes(cron, v.timezone) < MIN_GAP_MINUTES
81
+ ? "A recipe runs at most once an hour"
82
+ : null;
83
+ }
84
+
85
+ export function TimerFields({
86
+ value,
87
+ onChange,
88
+ }: {
89
+ value: TimerValue;
90
+ onChange: (v: TimerValue) => void;
91
+ }) {
92
+ const set = (patch: Partial<TimerValue>) => onChange({ ...value, ...patch });
93
+ const cron = timerCron(value);
94
+ const problem = timerProblem(value);
95
+ const [openedAt] = useState(() => Date.now());
96
+ const upcoming = useMemo(
97
+ () => (cron && !problem ? nextRuns(cron, openedAt, 3, value.timezone) : []),
98
+ [cron, problem, value.timezone, openedAt]
99
+ );
100
+ return (
101
+ <div className="space-y-3">
102
+ <div
103
+ role="radiogroup"
104
+ aria-label="Timer"
105
+ className="bg-foreground/[0.04] grid grid-cols-3 gap-1 rounded-lg p-1 md:flex"
106
+ >
107
+ {MODES.map((m) => (
108
+ <button
109
+ key={m.value}
110
+ type="button"
111
+ role="radio"
112
+ aria-checked={value.mode === m.value}
113
+ onClick={() => set({ mode: m.value })}
114
+ className={cn(
115
+ "min-h-11 min-w-0 flex-1 truncate rounded-md px-1 text-xs font-medium md:min-h-8 md:px-3 md:text-sm",
116
+ value.mode === m.value
117
+ ? "bg-background text-foreground shadow-sm"
118
+ : "text-muted-foreground hover:text-foreground"
119
+ )}
120
+ >
121
+ {m.label}
122
+ </button>
123
+ ))}
124
+ </div>
125
+ {value.mode === "weekly" && (
126
+ <select
127
+ aria-label="Day"
128
+ className={select}
129
+ value={value.weekday}
130
+ onChange={(e) => set({ weekday: Number(e.target.value) })}
131
+ >
132
+ {DAYS.map((d, i) => (
133
+ <option key={d} value={i}>
134
+ {d}
135
+ </option>
136
+ ))}
137
+ </select>
138
+ )}
139
+ {(value.mode === "daily" ||
140
+ value.mode === "weekdays" ||
141
+ value.mode === "weekly") && (
142
+ <span className="ml-0 flex items-center gap-2">
143
+ <select
144
+ aria-label="Hour"
145
+ className={select}
146
+ value={value.hour}
147
+ onChange={(e) => set({ hour: Number(e.target.value) })}
148
+ >
149
+ {Array.from({ length: 24 }, (_, h) => (
150
+ <option key={h} value={h}>
151
+ {clock12(h, 0).replace(":00", "")}
152
+ </option>
153
+ ))}
154
+ </select>
155
+ :
156
+ <select
157
+ aria-label="Minute"
158
+ className={select}
159
+ value={value.minute}
160
+ onChange={(e) => set({ minute: Number(e.target.value) })}
161
+ >
162
+ {Array.from({ length: 12 }, (_, i) => i * 5).map((m) => (
163
+ <option key={m} value={m}>
164
+ {String(m).padStart(2, "0")}
165
+ </option>
166
+ ))}
167
+ </select>
168
+ </span>
169
+ )}
170
+ {value.mode === "custom" && (
171
+ <Input
172
+ aria-label="Cron expression"
173
+ className="font-mono"
174
+ value={value.custom}
175
+ onChange={(e) => set({ custom: e.target.value })}
176
+ />
177
+ )}
178
+ {cron && (
179
+ <p
180
+ className={cn(
181
+ "text-xs",
182
+ problem ? "text-destructive" : "text-muted-foreground"
183
+ )}
184
+ >
185
+ {problem ??
186
+ `${describeCron(cron)} · next ${upcoming
187
+ .map((t) => formatRunTime(t, value.timezone))
188
+ .join(", ")}`}
189
+ </p>
190
+ )}
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,51 @@
1
+ import type { RecipeRun, RunStatus } from "@/lib/recipes/types";
2
+
3
+ export const STATUS_LABEL: Record<RunStatus, string> = {
4
+ queued: "Queued",
5
+ starting: "Starting",
6
+ running: "Running",
7
+ succeeded: "Succeeded",
8
+ failed: "Failed",
9
+ blocked: "Needs you",
10
+ timed_out: "Timed out",
11
+ cancelled: "Cancelled",
12
+ skipped: "Skipped",
13
+ };
14
+
15
+ export const STATUS_TONE: Record<RunStatus, string> = {
16
+ queued: "bg-foreground/10 text-muted-foreground",
17
+ starting: "bg-primary/15 text-primary",
18
+ running: "bg-primary/15 text-primary",
19
+ succeeded: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
20
+ failed: "bg-destructive/15 text-destructive",
21
+ blocked: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
22
+ timed_out: "bg-destructive/15 text-destructive",
23
+ cancelled: "bg-foreground/10 text-muted-foreground",
24
+ skipped: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
25
+ };
26
+
27
+ // SQLite's datetime('now') is UTC without a zone marker.
28
+ export const sqlMs = (t: string | null) =>
29
+ t ? Date.parse(`${t.replace(" ", "T")}Z`) : 0;
30
+
31
+ export function duration(ms: number | null): string {
32
+ if (ms == null) return "";
33
+ const s = Math.round(ms / 1000);
34
+ if (s < 60) return `${s}s`;
35
+ const m = Math.round(s / 60);
36
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
37
+ }
38
+
39
+ export const money = (n: number | null) =>
40
+ n == null ? "" : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`;
41
+
42
+ const TRIGGER_LABEL = {
43
+ manual: "by hand",
44
+ tool: "by the orchestrator",
45
+ cli: "by an agent",
46
+ timer: "on its timer",
47
+ "catch-up": "on its timer, late",
48
+ } as const;
49
+
50
+ export const triggerLabel = (r: Pick<RecipeRun, "trigger">) =>
51
+ TRIGGER_LABEL[r.trigger];
@@ -0,0 +1 @@
1
+ export { RecipesPanel } from "./RecipesPanel";
@@ -40,6 +40,7 @@ const KIND_LABEL = {
40
40
  session: "Session",
41
41
  orchestrator: "Orchestrator",
42
42
  message: "Message",
43
+ recipe: "Recipe",
43
44
  } as const;
44
45
 
45
46
  // "Task · api", "Message → orchestrator".
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import { Repeat, Layers } from "lucide-react";
3
4
  import { HostBadge } from "@/components/Hosts";
5
+ import { rowGroup } from "@/lib/recipes/chip";
4
6
  import type { Session } from "@/lib/db";
5
7
  import { LiveLine, type LiveInput } from "./LiveLine";
6
8
  import { headerParts } from "@/lib/orchestrator/header-line";
@@ -53,9 +55,23 @@ export function RowSubtitle({
53
55
  );
54
56
  }
55
57
  const project = ctx.projectNames.get(session.project_id ?? "");
58
+ const group = rowGroup(session);
56
59
  return (
57
60
  <span className="flex min-w-0 items-center gap-1.5">
58
61
  <LiveLine {...live} />
62
+ {group && (
63
+ <span
64
+ className="text-muted-foreground/70 flex min-w-0 shrink-0 items-center gap-1 truncate text-xs"
65
+ title={group.kind === "recipe" ? "A recipe run" : "A piece of a plan"}
66
+ >
67
+ {group.kind === "recipe" ? (
68
+ <Repeat aria-hidden className="h-3 w-3 shrink-0" />
69
+ ) : (
70
+ <Layers aria-hidden className="h-3 w-3 shrink-0" />
71
+ )}
72
+ <span className="truncate">{group.text}</span>
73
+ </span>
74
+ )}
59
75
  {(project || detail || usage) && (
60
76
  <span className="text-muted-foreground/70 truncate text-xs">
61
77
  {[usage && usageLabel(usage), detail?.slice(0, 200), project]
@@ -14,6 +14,9 @@ import {
14
14
  Loader2,
15
15
  Pencil,
16
16
  Pin,
17
+ Play,
18
+ Repeat,
19
+ ChefHat,
17
20
  PinOff,
18
21
  RotateCcw,
19
22
  Sparkles,
@@ -24,6 +27,8 @@ import * as CM from "@/components/ui/context-menu";
24
27
  import type { Session } from "@/lib/db";
25
28
  import { selectionActions } from "@/stores/sessionSelection";
26
29
  import { schedulesUiActions } from "@/stores/schedulesUi";
30
+ import { recipesUiActions } from "@/stores/recipesUi";
31
+ import { rowGroup } from "@/lib/recipes/chip";
27
32
  import { useDoneAction } from "./useDoneAction";
28
33
  import { useRowContext } from "./RowContext";
29
34
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
@@ -95,6 +100,8 @@ export function SessionRowMenu({
95
100
  const others = ctx.projects.filter(
96
101
  (p) => p.id !== session.project_id && !p.is_uncategorized
97
102
  );
103
+ const group = rowGroup(session);
104
+ const recipeRun = group?.kind === "recipe" ? { id: group.id } : null;
98
105
  const workspaceId =
99
106
  session.workspace_id ??
100
107
  ctx.projects.find((p) => p.id === session.project_id)?.workspace_id ??
@@ -251,6 +258,28 @@ export function SessionRowMenu({
251
258
  Schedule check-ins
252
259
  </Item>
253
260
  )}
261
+ {recipeRun && (
262
+ <>
263
+ <Item onClick={() => recipesUiActions.openRecipe(recipeRun.id)}>
264
+ <Repeat className={icon} />
265
+ Open recipe
266
+ </Item>
267
+ <Item onClick={() => recipesUiActions.openRecipe(recipeRun.id, true)}>
268
+ <Play className={icon} />
269
+ Run again
270
+ </Item>
271
+ </>
272
+ )}
273
+ {!recipeRun && !session.role && !session.quick_of && (
274
+ <Item
275
+ onClick={() =>
276
+ recipesUiActions.saveSessionAs(session.id, workspaceId)
277
+ }
278
+ >
279
+ <ChefHat className={icon} />
280
+ Save as recipe
281
+ </Item>
282
+ )}
254
283
  <Item
255
284
  onClick={() =>
256
285
  selectionActions.toggle(session.id, false, ctx.orderedIds())
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  Archive,
5
5
  BookOpen,
6
+ ChefHat,
6
7
  Clock,
7
8
  FolderGit2,
8
9
  FolderPlus,
@@ -31,6 +32,7 @@ import { archivedUiActions } from "@/stores/archivedUi";
31
32
  import { tasksUiActions } from "@/stores/tasksUi";
32
33
  import { newDraft } from "@/stores/drafts";
33
34
  import { schedulesUiActions } from "@/stores/schedulesUi";
35
+ import { recipesUiActions } from "@/stores/recipesUi";
34
36
  import { busUiActions } from "@/stores/busUi";
35
37
  import { settingsUiActions } from "@/stores/settingsUi";
36
38
  import { usageUiActions } from "@/stores/usageUi";
@@ -106,6 +108,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
106
108
  </span>
107
109
  ),
108
110
  }),
111
+ menuItem("Recipes", () => recipesUiActions.open(workspaceId), {
112
+ icon: ChefHat,
113
+ }),
109
114
  menuItem("Messages", busUiActions.open, { icon: MessagesSquare }),
110
115
  ...(props.onOpenDocs
111
116
  ? [menuItem("Docs", props.onOpenDocs, { icon: BookOpen })]
@@ -4,6 +4,7 @@ import { useState } from "react";
4
4
  import {
5
5
  Archive,
6
6
  Check,
7
+ ChefHat,
7
8
  ChevronDown,
8
9
  CircleCheck,
9
10
  Clock,
@@ -30,6 +31,7 @@ import { archivedUiActions, cleanupUiActions } from "@/stores/archivedUi";
30
31
  import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
31
32
  import { sidebarUiActions } from "@/stores/sidebarUi";
32
33
  import { schedulesUiActions } from "@/stores/schedulesUi";
34
+ import { recipesUiActions } from "@/stores/recipesUi";
33
35
  import { useAddExistingProject } from "@/components/Projects/AddProject/useAddExistingProject";
34
36
 
35
37
  const icon = "mr-2 h-3.5 w-3.5";
@@ -89,10 +91,18 @@ export function WorkspaceSwitcher({
89
91
  New workspace
90
92
  </DM.DropdownMenuItem>
91
93
  {!current && (
92
- <DM.DropdownMenuItem onClick={() => schedulesUiActions.open(null)}>
93
- <Clock className={icon} />
94
- Schedules
95
- </DM.DropdownMenuItem>
94
+ <>
95
+ <DM.DropdownMenuItem onClick={() => recipesUiActions.open(null)}>
96
+ <ChefHat className={icon} />
97
+ Recipes
98
+ </DM.DropdownMenuItem>
99
+ <DM.DropdownMenuItem
100
+ onClick={() => schedulesUiActions.open(null)}
101
+ >
102
+ <Clock className={icon} />
103
+ Schedules
104
+ </DM.DropdownMenuItem>
105
+ </>
96
106
  )}
97
107
  {current && (
98
108
  <>
@@ -110,6 +120,12 @@ export function WorkspaceSwitcher({
110
120
  <Workflow className={icon} />
111
121
  Orchestrator
112
122
  </DM.DropdownMenuItem>
123
+ <DM.DropdownMenuItem
124
+ onClick={() => recipesUiActions.open(current.id)}
125
+ >
126
+ <ChefHat className={icon} />
127
+ Recipes
128
+ </DM.DropdownMenuItem>
113
129
  <DM.DropdownMenuItem
114
130
  onClick={() => schedulesUiActions.open(current.id)}
115
131
  >
@@ -6,6 +6,7 @@ import {
6
6
  ArrowLeft,
7
7
  Bell,
8
8
  ChevronRight,
9
+ ChefHat,
9
10
  ClipboardList,
10
11
  Clock,
11
12
  Cpu,
@@ -34,6 +35,7 @@ import { useSettingsUrl } from "@/hooks/useSettingsUrl";
34
35
  import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
35
36
  import { cn } from "@/lib/utils";
36
37
  import { schedulesUiActions } from "@/stores/schedulesUi";
38
+ import { RecipesPanel } from "@/components/Recipes";
37
39
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
38
40
  import { SetupSection } from "./SetupSection";
39
41
  import {
@@ -70,6 +72,11 @@ export const SECTION_INFO: Record<
70
72
  hint: "Sound, browser alerts, your phone",
71
73
  icon: Bell,
72
74
  },
75
+ recipes: {
76
+ label: "Recipes",
77
+ hint: "Saved jobs you run again, by hand or on a timer",
78
+ icon: ChefHat,
79
+ },
73
80
  schedules: {
74
81
  label: "Schedules",
75
82
  hint: "Work that starts at set times",
@@ -105,6 +112,8 @@ function SectionBody({
105
112
  return <DevicesPanel />;
106
113
  case "notifications":
107
114
  return <NotificationsSection browser={browser} />;
115
+ case "recipes":
116
+ return <RecipesPanel />;
108
117
  case "schedules":
109
118
  return <SchedulesPanel />;
110
119
  case "audits":