@saadnvd1/agent-os 0.3.533 → 0.3.535

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 (86) hide show
  1. package/README.md +74 -4
  2. package/app/api/features/pieces/[id]/release/route.ts +16 -0
  3. package/app/api/features/route.ts +8 -0
  4. package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
  5. package/app/api/recipes/[id]/preview/route.ts +19 -0
  6. package/app/api/recipes/[id]/route.ts +51 -0
  7. package/app/api/recipes/[id]/run/route.ts +35 -0
  8. package/app/api/recipes/[id]/runs/route.ts +23 -0
  9. package/app/api/recipes/draft/route.ts +14 -0
  10. package/app/api/recipes/route.ts +35 -0
  11. package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
  12. package/components/CommandPalette/useAppCommands.ts +20 -0
  13. package/components/Features/FeaturesSection.tsx +100 -0
  14. package/components/Features/index.ts +1 -0
  15. package/components/Recipes/RecipeForm.tsx +251 -0
  16. package/components/Recipes/RecipePage.tsx +127 -0
  17. package/components/Recipes/RecipesPanel.tsx +225 -0
  18. package/components/Recipes/RunChip.tsx +16 -0
  19. package/components/Recipes/RunHistory.tsx +88 -0
  20. package/components/Recipes/RunSheet.tsx +84 -0
  21. package/components/Recipes/TimerFields.tsx +193 -0
  22. package/components/Recipes/format.ts +51 -0
  23. package/components/Recipes/index.ts +1 -0
  24. package/components/Schedules/ScheduleForm.tsx +20 -2
  25. package/components/Schedules/SchedulesPanel.tsx +1 -0
  26. package/components/SessionList/RowSubtitle.tsx +16 -0
  27. package/components/SessionList/SessionRowMenu.tsx +29 -0
  28. package/components/SessionList/SidebarHeader.tsx +5 -0
  29. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  30. package/components/Settings/SettingsDialog.tsx +9 -0
  31. package/components/Tasks/PlanReview.tsx +253 -0
  32. package/components/Tasks/TaskRow.tsx +19 -44
  33. package/components/Tasks/TasksDialog.tsx +2 -0
  34. package/data/features/index.ts +40 -0
  35. package/data/push/topics.ts +8 -0
  36. package/data/recipes/index.ts +129 -0
  37. package/data/tasks/index.ts +14 -0
  38. package/lib/away/run.ts +4 -1
  39. package/lib/db/changes.ts +8 -0
  40. package/lib/db/migrations.ts +143 -0
  41. package/lib/db/queries.ts +4 -1
  42. package/lib/db/schedules-move.ts +220 -0
  43. package/lib/db/types.ts +6 -0
  44. package/lib/models/plan-step.ts +67 -6
  45. package/lib/notify/copy.ts +28 -10
  46. package/lib/notify/push/events.ts +36 -2
  47. package/lib/notify/push/policy.ts +2 -0
  48. package/lib/notify/push-types.ts +7 -3
  49. package/lib/orchestrator/brief.ts +13 -0
  50. package/lib/orchestrator/conditions.ts +3 -1
  51. package/lib/orchestrator/deliver.ts +2 -0
  52. package/lib/orchestrator/digest.ts +27 -1
  53. package/lib/orchestrator/plan-pieces.ts +41 -0
  54. package/lib/orchestrator/recipe-tools.ts +159 -0
  55. package/lib/orchestrator/review.ts +2 -1
  56. package/lib/orchestrator/serve.ts +25 -0
  57. package/lib/orchestrator/tool-names.ts +5 -0
  58. package/lib/orchestrator/tool-schemas.ts +54 -0
  59. package/lib/orchestrator/tools.ts +10 -0
  60. package/lib/plans/features.ts +200 -0
  61. package/lib/plans/pieces.ts +366 -0
  62. package/lib/plans/release.ts +323 -0
  63. package/lib/plans/view.ts +85 -0
  64. package/lib/recipes/api.ts +19 -0
  65. package/lib/recipes/chip.ts +40 -0
  66. package/lib/recipes/compose.ts +78 -0
  67. package/lib/recipes/draft.ts +93 -0
  68. package/lib/recipes/index.ts +54 -0
  69. package/lib/recipes/result.ts +59 -0
  70. package/lib/recipes/run.ts +202 -0
  71. package/lib/recipes/settle.ts +465 -0
  72. package/lib/recipes/store.ts +378 -0
  73. package/lib/recipes/test-fixtures.ts +49 -0
  74. package/lib/recipes/timer.ts +107 -0
  75. package/lib/recipes/types.ts +116 -0
  76. package/lib/schedules/index.ts +4 -1
  77. package/lib/schedules/start.ts +33 -9
  78. package/lib/schedules/store.ts +6 -2
  79. package/lib/security/demo.ts +1 -0
  80. package/lib/settings-url.ts +1 -0
  81. package/lib/tasks/loop-types.ts +23 -0
  82. package/lib/tasks/plan-view.ts +38 -1
  83. package/package.json +1 -1
  84. package/scripts/revert-schedules-move.ts +24 -0
  85. package/server.ts +2 -0
  86. package/stores/recipesUi.ts +53 -0
@@ -0,0 +1,253 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Textarea } from "@/components/ui/textarea";
6
+ import { Markdown } from "@/components/Chat/Markdown";
7
+ import { useApprovePlan, useDropTask, usePlanEdits } from "@/data/tasks";
8
+ import type { PlanPieceView } from "@/lib/tasks/loop-types";
9
+ import type { TaskView } from "@/lib/tasks";
10
+ import { cn } from "@/lib/utils";
11
+
12
+ const CIRCLED = ["①", "②", "③", "④", "⑤", "⑥"];
13
+ const REASON: Record<string, string> = {
14
+ a: "spans repositories",
15
+ b: "must be live before the other can merge",
16
+ c: "needs you mid-way",
17
+ d: "must be revertible on its own",
18
+ };
19
+
20
+ function Piece({
21
+ p,
22
+ edits,
23
+ busy,
24
+ onEdit,
25
+ }: {
26
+ p: PlanPieceView;
27
+ edits: { merge: number[]; drop: number[] };
28
+ busy: boolean;
29
+ onEdit: (merge: number[], drop: number[]) => void;
30
+ }) {
31
+ const mark = CIRCLED[p.position - 1];
32
+ const off = p.edit !== null;
33
+ const without = (list: number[]) => list.filter((n) => n !== p.position);
34
+ return (
35
+ <li className={cn("space-y-1 px-3 py-2.5", off && "opacity-60")}>
36
+ <div className="flex items-baseline gap-2">
37
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">
38
+ {mark} {p.name}
39
+ </span>
40
+ <span className="text-muted-foreground shrink-0 text-xs">
41
+ {p.project}
42
+ </span>
43
+ </div>
44
+ <p className="text-muted-foreground text-xs">{p.slice.split("\n")[0]}</p>
45
+ {p.afterPosition && (
46
+ <p className="text-xs">
47
+ after {CIRCLED[p.afterPosition - 1]} is {p.afterKind}
48
+ </p>
49
+ )}
50
+ {p.reasons.length > 0 && (
51
+ <p className="text-xs">
52
+ {p.reasons.map((r) => `(${r}) ${REASON[r]}`).join("; ")}
53
+ </p>
54
+ )}
55
+ {p.owns.length > 0 && (
56
+ <p className="text-muted-foreground text-xs">
57
+ owns: {p.owns.join(", ")}
58
+ </p>
59
+ )}
60
+ {p.position > 1 && (
61
+ <div className="flex flex-wrap items-center gap-2 pt-1">
62
+ {off ? (
63
+ <>
64
+ <span className="bg-foreground/10 rounded-full px-2 py-0.5 text-[11px]">
65
+ {p.edit === "merge"
66
+ ? `Merged into ${CIRCLED[p.position - 2]}`
67
+ : "Dropped"}
68
+ </span>
69
+ <Button
70
+ size="sm"
71
+ variant="ghost"
72
+ className="h-11 md:h-8"
73
+ disabled={busy}
74
+ onClick={() =>
75
+ onEdit(without(edits.merge), without(edits.drop))
76
+ }
77
+ >
78
+ Undo
79
+ </Button>
80
+ </>
81
+ ) : (
82
+ <>
83
+ <Button
84
+ size="sm"
85
+ variant="outline"
86
+ className="h-11 md:h-8"
87
+ disabled={busy || p.forced}
88
+ title={
89
+ p.forced ? "Split because it spans repositories" : undefined
90
+ }
91
+ onClick={() => onEdit([...edits.merge, p.position], edits.drop)}
92
+ >
93
+ Merge into {CIRCLED[p.position - 2]}
94
+ </Button>
95
+ <Button
96
+ size="sm"
97
+ variant="ghost"
98
+ className="h-11 md:h-8"
99
+ disabled={busy}
100
+ onClick={() => onEdit(edits.merge, [...edits.drop, p.position])}
101
+ >
102
+ Drop
103
+ </Button>
104
+ </>
105
+ )}
106
+ </div>
107
+ )}
108
+ </li>
109
+ );
110
+ }
111
+
112
+ // A plan waiting for a decision: how it splits, the plan itself, and
113
+ // Reject, Approve with a note, Approve.
114
+ export function PlanReview({ task }: { task: TaskView }) {
115
+ const approve = useApprovePlan();
116
+ const drop = useDropTask();
117
+ const planEdits = usePlanEdits();
118
+ const [showPlan, setShowPlan] = useState(false);
119
+ const [noting, setNoting] = useState(false);
120
+ const [note, setNote] = useState("");
121
+ const [rejecting, setRejecting] = useState(false);
122
+ const pieces = task.plan?.pieces;
123
+ const edits = {
124
+ merge:
125
+ pieces?.pieces.filter((p) => p.edit === "merge").map((p) => p.position) ??
126
+ [],
127
+ drop:
128
+ pieces?.pieces.filter((p) => p.edit === "drop").map((p) => p.position) ??
129
+ [],
130
+ };
131
+ const error =
132
+ approve.error?.message || drop.error?.message || planEdits.error?.message;
133
+ return (
134
+ <div className="space-y-3">
135
+ {pieces && pieces.pieces.length > 1 ? (
136
+ <div className="space-y-1">
137
+ <p className="label-mono text-muted-foreground">
138
+ Pieces ({pieces.pieces.length})
139
+ </p>
140
+ <ul className="divide-border/60 border-border/60 divide-y rounded-lg border">
141
+ {pieces.pieces.map((p) => (
142
+ <Piece
143
+ key={p.position}
144
+ p={p}
145
+ edits={edits}
146
+ busy={planEdits.isPending}
147
+ onEdit={(merge, dropped) =>
148
+ planEdits.mutate({ id: task.id, merge, drop: dropped })
149
+ }
150
+ />
151
+ ))}
152
+ </ul>
153
+ </div>
154
+ ) : (
155
+ <p className="text-muted-foreground text-xs">1 piece</p>
156
+ )}
157
+ {pieces?.folded.map((f) => (
158
+ <p key={f.name} className="text-xs text-amber-700 dark:text-amber-300">
159
+ &quot;{f.name}&quot; folded into {CIRCLED[f.into - 1]}: {f.why}
160
+ </p>
161
+ ))}
162
+ {pieces?.warnings.map((w) => (
163
+ <p key={w} className="text-xs text-amber-700 dark:text-amber-300">
164
+ {w}
165
+ </p>
166
+ ))}
167
+ <button
168
+ type="button"
169
+ aria-expanded={showPlan}
170
+ className="text-primary min-h-11 text-xs font-medium md:min-h-8"
171
+ onClick={() => setShowPlan((v) => !v)}
172
+ >
173
+ {showPlan ? "Hide the plan" : "Show the plan"}
174
+ </button>
175
+ {showPlan && (
176
+ <div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
177
+ {task.plan?.text ? (
178
+ <Markdown text={task.plan.text} />
179
+ ) : (
180
+ <p className="text-muted-foreground text-xs">
181
+ No plan file was written.
182
+ </p>
183
+ )}
184
+ {task.plan?.truncated && (
185
+ <p className="text-muted-foreground mt-2 font-mono text-[11px]">
186
+ Shortened; the whole plan is {task.plan.path} in the worktree.
187
+ </p>
188
+ )}
189
+ </div>
190
+ )}
191
+ {noting && (
192
+ <div className="space-y-2">
193
+ <Textarea
194
+ rows={3}
195
+ value={note}
196
+ placeholder="A change to the plan (recorded as a scope change)"
197
+ onChange={(e) => setNote(e.target.value)}
198
+ />
199
+ <Button
200
+ size="sm"
201
+ className="h-11 md:h-8"
202
+ disabled={approve.isPending || !note.trim()}
203
+ onClick={() =>
204
+ approve.mutate({ id: task.id, message: note.trim() })
205
+ }
206
+ >
207
+ Approve with this note
208
+ </Button>
209
+ </div>
210
+ )}
211
+ <div className="flex flex-wrap gap-2">
212
+ {rejecting ? (
213
+ <Button
214
+ size="sm"
215
+ variant="destructive"
216
+ className="h-11 md:h-8"
217
+ disabled={drop.isPending}
218
+ onClick={() => drop.mutate(task.id)}
219
+ >
220
+ Drop this plan
221
+ </Button>
222
+ ) : (
223
+ <Button
224
+ size="sm"
225
+ variant="ghost"
226
+ className="h-11 md:h-8"
227
+ onClick={() => setRejecting(true)}
228
+ >
229
+ Reject
230
+ </Button>
231
+ )}
232
+ <Button
233
+ size="sm"
234
+ variant="outline"
235
+ className="h-11 md:h-8"
236
+ aria-expanded={noting}
237
+ onClick={() => setNoting((v) => !v)}
238
+ >
239
+ Approve with note…
240
+ </Button>
241
+ <Button
242
+ size="sm"
243
+ className="h-11 md:h-8"
244
+ disabled={approve.isPending}
245
+ onClick={() => approve.mutate({ id: task.id })}
246
+ >
247
+ {approve.isPending ? "Approving..." : "Approve"}
248
+ </Button>
249
+ </div>
250
+ {error && <p className="text-destructive text-xs">{error}</p>}
251
+ </div>
252
+ );
253
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import {
5
+ ChefHat,
5
6
  ExternalLink,
6
7
  GitMerge,
7
8
  KanbanSquare,
@@ -10,17 +11,13 @@ import {
10
11
  import { Button } from "@/components/ui/button";
11
12
  import type { TaskView } from "@/lib/tasks";
12
13
  import { isFinished } from "@/lib/tasks/state";
13
- import {
14
- useApprovePlan,
15
- useArmTask,
16
- useDropTask,
17
- useSignOffTask,
18
- } from "@/data/tasks";
19
- import { Markdown } from "@/components/Chat/Markdown";
14
+ import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
20
15
  import { tmuxAttachActions } from "@/stores/tmuxAttach";
21
16
  import { tasksUiActions } from "@/stores/tasksUi";
17
+ import { recipesUiActions } from "@/stores/recipesUi";
22
18
  import { cn } from "@/lib/utils";
23
19
  import { SimulatorIndicator } from "@/components/Simulator";
20
+ import { PlanReview } from "./PlanReview";
24
21
  import { MoveTaskButton } from "./MoveTaskButton";
25
22
  import { ResumeOnModel } from "./ResumeOnModel";
26
23
  import { TASK_STATE as STATE } from "./task-state-labels";
@@ -30,8 +27,6 @@ export function TaskRow({ task }: { task: TaskView }) {
30
27
  const signOff = useSignOffTask();
31
28
  const drop = useDropTask();
32
29
  const arm = useArmTask();
33
- const approve = useApprovePlan();
34
- const [showPlan, setShowPlan] = useState(false);
35
30
  const [confirmDrop, setConfirmDrop] = useState(false);
36
31
  const [moveError, setMoveError] = useState<string | null>(null);
37
32
  const live = !isFinished(task.state);
@@ -39,7 +34,6 @@ export function TaskRow({ task }: { task: TaskView }) {
39
34
  signOff.error?.message ||
40
35
  drop.error?.message ||
41
36
  arm.error?.message ||
42
- approve.error?.message ||
43
37
  moveError ||
44
38
  task.hostError;
45
39
  const settingUp = live && task.setup?.status === "running";
@@ -159,26 +153,6 @@ export function TaskRow({ task }: { task: TaskView }) {
159
153
  {task.armed ? "Armed" : "Arm merge"}
160
154
  </Button>
161
155
  )}
162
- {task.state === "plan-ready" && (
163
- <>
164
- <Button
165
- size="sm"
166
- variant="outline"
167
- aria-expanded={showPlan}
168
- onClick={() => setShowPlan((v) => !v)}
169
- >
170
- {showPlan ? "Hide plan" : "View plan"}
171
- </Button>
172
- <Button
173
- size="sm"
174
- className="h-11 md:h-8"
175
- disabled={approve.isPending}
176
- onClick={() => approve.mutate({ id: task.id })}
177
- >
178
- {approve.isPending ? "Approving..." : "Approve"}
179
- </Button>
180
- </>
181
- )}
182
156
  {task.state === "review" && (
183
157
  <Button
184
158
  size="sm"
@@ -190,20 +164,21 @@ export function TaskRow({ task }: { task: TaskView }) {
190
164
  )}
191
165
  </div>
192
166
  )}
193
- {live && task.state === "plan-ready" && showPlan && (
194
- <div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
195
- {task.plan?.text ? (
196
- <Markdown text={task.plan.text} />
197
- ) : (
198
- <p className="text-muted-foreground text-xs">
199
- No plan file was written.
200
- </p>
201
- )}
202
- {task.plan?.truncated && (
203
- <p className="text-muted-foreground mt-2 font-mono text-[11px]">
204
- Shortened; the whole plan is {task.plan.path} in the worktree.
205
- </p>
206
- )}
167
+ {live && task.state === "plan-ready" && <PlanReview task={task} />}
168
+ {!live && !task.hostId && (
169
+ <div>
170
+ <Button
171
+ size="sm"
172
+ variant="ghost"
173
+ className="text-muted-foreground h-11 md:h-8"
174
+ onClick={() => {
175
+ recipesUiActions.saveSessionAs(task.id);
176
+ tasksUiActions.setPanelOpen(false);
177
+ }}
178
+ >
179
+ <ChefHat className="h-3.5 w-3.5" />
180
+ Save as recipe
181
+ </Button>
207
182
  </div>
208
183
  )}
209
184
  {error && <p className="text-destructive text-xs">{error}</p>}
@@ -16,6 +16,7 @@ import { newDraft } from "@/stores/drafts";
16
16
  import { TaskRow } from "./TaskRow";
17
17
  import { BoardTodoSection } from "@/components/LumifyHub";
18
18
  import { StacksSection } from "@/components/Stacks";
19
+ import { FeaturesSection } from "@/components/Features";
19
20
 
20
21
  export function TasksDialog() {
21
22
  const { panelOpen } = useSnapshot(tasksUi);
@@ -57,6 +58,7 @@ export function TasksDialog() {
57
58
  <TaskRow key={t.id} task={t} />
58
59
  ))}
59
60
  </div>
61
+ <FeaturesSection />
60
62
  <StacksSection />
61
63
  <BoardTodoSection />
62
64
  {done.length > 0 && (
@@ -0,0 +1,40 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { FeatureView } from "@/lib/plans/view";
3
+ import { usePollWhenOffline } from "../push/connection";
4
+
5
+ export const featureKeys = {
6
+ all: ["features"] as const,
7
+ list: (workspaceId: string | null) =>
8
+ [...featureKeys.all, "list", workspaceId ?? "all"] as const,
9
+ };
10
+
11
+ export function useFeaturesQuery(workspaceId: string | null = null) {
12
+ return useQuery({
13
+ queryKey: featureKeys.list(workspaceId),
14
+ queryFn: async () => {
15
+ const res = await fetch(
16
+ `/api/features${workspaceId ? `?workspace=${workspaceId}` : ""}`
17
+ );
18
+ const data = await res.json();
19
+ if (!res.ok) throw new Error(data.error || "Request failed");
20
+ return data.features as FeatureView[];
21
+ },
22
+ // Pushed when features or their pieces change.
23
+ refetchInterval: usePollWhenOffline(20000),
24
+ });
25
+ }
26
+
27
+ export function useReleasePiece() {
28
+ const queryClient = useQueryClient();
29
+ return useMutation({
30
+ mutationFn: async (id: number) => {
31
+ const res = await fetch(`/api/features/pieces/${id}/release`, {
32
+ method: "POST",
33
+ });
34
+ const data = await res.json();
35
+ if (!res.ok) throw new Error(data.error || "Request failed");
36
+ },
37
+ onSettled: () =>
38
+ queryClient.invalidateQueries({ queryKey: featureKeys.all }),
39
+ });
40
+ }
@@ -12,6 +12,8 @@ import { orchestratorKeys } from "../orchestrators";
12
12
  import { ruleKeys } from "../orchestrators/rules";
13
13
  import { workspaceKeys } from "../workspaces";
14
14
  import { scheduleKeys } from "../schedules";
15
+ import { recipeKeys } from "../recipes";
16
+ import { featureKeys } from "../features";
15
17
  import { archivedKeys } from "../done";
16
18
  import { awayKeys } from "../away";
17
19
 
@@ -54,6 +56,12 @@ function keysFor(topic: string): QueryKey[] {
54
56
  case "schedules":
55
57
  case "schedule_runs":
56
58
  return [scheduleKeys.all];
59
+ case "recipes":
60
+ case "recipe_runs":
61
+ return [recipeKeys.all];
62
+ case "features":
63
+ case "feature_pieces":
64
+ return [featureKeys.all, taskKeys.all, sessionKeys.all];
57
65
  case "orchestrator_asks":
58
66
  case "orchestrator_questions":
59
67
  case "needs_you_dismissed":
@@ -0,0 +1,129 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { RecipeInput, RecipeRun, RecipeView } from "@/lib/recipes/types";
3
+ import type { RecipeDraft } from "@/lib/recipes/draft";
4
+ import type { TimerInput } from "@/lib/recipes/timer";
5
+ import { sessionKeys } from "../sessions/keys";
6
+ import { taskKeys } from "../tasks";
7
+ import { usePollWhenOffline } from "../push/connection";
8
+
9
+ export const recipeKeys = {
10
+ all: ["recipes"] as const,
11
+ list: (workspaceId: string | null) =>
12
+ [...recipeKeys.all, "list", workspaceId ?? "all"] as const,
13
+ detail: (id: string) => [...recipeKeys.all, "detail", id] as const,
14
+ preview: (id: string, note: string) =>
15
+ [...recipeKeys.all, "preview", id, note] as const,
16
+ };
17
+
18
+ async function call<T>(
19
+ url: string,
20
+ method = "GET",
21
+ body?: unknown
22
+ ): Promise<T> {
23
+ const res = await fetch(url, {
24
+ method,
25
+ headers:
26
+ body === undefined ? undefined : { "Content-Type": "application/json" },
27
+ body: body === undefined ? undefined : JSON.stringify(body),
28
+ });
29
+ const data = await res.json();
30
+ if (!res.ok) throw new Error(data.error || "Request failed");
31
+ return data;
32
+ }
33
+
34
+ export function useRecipesQuery(workspaceId: string | null, enabled = true) {
35
+ return useQuery({
36
+ queryKey: recipeKeys.list(workspaceId),
37
+ queryFn: async () =>
38
+ (
39
+ await call<{ recipes: RecipeView[] }>(
40
+ `/api/recipes${workspaceId ? `?workspace=${workspaceId}` : ""}`
41
+ )
42
+ ).recipes,
43
+ enabled,
44
+ // Pushed when recipes or their runs change.
45
+ refetchInterval: usePollWhenOffline(15000),
46
+ });
47
+ }
48
+
49
+ export function useRecipeQuery(id: string | null) {
50
+ return useQuery({
51
+ queryKey: recipeKeys.detail(id ?? ""),
52
+ queryFn: () =>
53
+ call<{ recipe: RecipeView; runs: RecipeRun[] }>(`/api/recipes/${id}`),
54
+ enabled: !!id,
55
+ refetchInterval: usePollWhenOffline(10000),
56
+ });
57
+ }
58
+
59
+ // What a run started now would send, for the Run sheet.
60
+ export function usePreviewQuery(id: string, note: string, enabled: boolean) {
61
+ return useQuery({
62
+ queryKey: recipeKeys.preview(id, note),
63
+ queryFn: () =>
64
+ call<{ prompt: string }>(
65
+ `/api/recipes/${id}/preview${note ? `?note=${encodeURIComponent(note)}` : ""}`
66
+ ),
67
+ enabled,
68
+ });
69
+ }
70
+
71
+ function useRecipeMutation<V, R>(fn: (v: V) => Promise<R>) {
72
+ const queryClient = useQueryClient();
73
+ return useMutation({
74
+ mutationFn: fn,
75
+ onSettled: () => {
76
+ queryClient.invalidateQueries({ queryKey: recipeKeys.all });
77
+ queryClient.invalidateQueries({ queryKey: taskKeys.all });
78
+ queryClient.invalidateQueries({ queryKey: sessionKeys.all });
79
+ },
80
+ });
81
+ }
82
+
83
+ export type RecipeFormInput = RecipeInput & { timer?: TimerInput | null };
84
+
85
+ export function useCreateRecipe() {
86
+ return useRecipeMutation((input: RecipeFormInput) =>
87
+ call<{ recipe: RecipeView }>("/api/recipes", "POST", input)
88
+ );
89
+ }
90
+
91
+ export function useUpdateRecipe() {
92
+ return useRecipeMutation(
93
+ ({
94
+ id,
95
+ ...patch
96
+ }: Partial<Omit<RecipeFormInput, "workspaceId">> & { id: string }) =>
97
+ call<{ recipe: RecipeView }>(`/api/recipes/${id}`, "PATCH", patch)
98
+ );
99
+ }
100
+
101
+ export function useArchiveRecipe() {
102
+ return useRecipeMutation((id: string) =>
103
+ call(`/api/recipes/${id}`, "DELETE")
104
+ );
105
+ }
106
+
107
+ export function useRunRecipe() {
108
+ return useRecipeMutation(
109
+ (input: { id: string; note?: string; requestId: string }) =>
110
+ call<{ run: RecipeRun; queued: boolean }>(
111
+ `/api/recipes/${input.id}/run`,
112
+ "POST",
113
+ { note: input.note, requestId: input.requestId }
114
+ )
115
+ );
116
+ }
117
+
118
+ export function useClearCarry() {
119
+ return useRecipeMutation((id: string) =>
120
+ call(`/api/recipes/${id}/carry/clear`, "POST", {})
121
+ );
122
+ }
123
+
124
+ export function useRecipeDraft() {
125
+ return useMutation({
126
+ mutationFn: (sessionId: string) =>
127
+ call<RecipeDraft>("/api/recipes/draft", "POST", { sessionId }),
128
+ });
129
+ }
@@ -97,6 +97,20 @@ export function useApprovePlan() {
97
97
  );
98
98
  }
99
99
 
100
+ // Which waiting pieces of a plan to fold into the one before, or leave out.
101
+ export function usePlanEdits() {
102
+ return useTaskMutation(
103
+ async (input: { id: string; merge: number[]; drop: number[] }) =>
104
+ json(
105
+ await fetch(`/api/tasks/${input.id}/plan-edits`, {
106
+ method: "PUT",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ merge: input.merge, drop: input.drop }),
109
+ })
110
+ )
111
+ );
112
+ }
113
+
100
114
  export function useResumeTask() {
101
115
  return useTaskMutation(async (input: { id: string; force?: boolean }) =>
102
116
  json(
package/lib/away/run.ts CHANGED
@@ -286,7 +286,10 @@ async function rerunReview(
286
286
 
287
287
  const SHA = /^[0-9a-f]{40}$/;
288
288
 
289
- async function mergeCommit(repo: string, pr: number): Promise<string | null> {
289
+ export async function mergeCommit(
290
+ repo: string,
291
+ pr: number
292
+ ): Promise<string | null> {
290
293
  const out = await run(
291
294
  "gh",
292
295
  [
package/lib/db/changes.ts CHANGED
@@ -34,6 +34,13 @@ export const TABLES_WATCHED_78 = [
34
34
  "needs_you_dismissed",
35
35
  ] as const;
36
36
 
37
+ export const TABLES_WATCHED_80 = [
38
+ "recipes",
39
+ "recipe_runs",
40
+ "features",
41
+ "feature_pieces",
42
+ ] as const;
43
+
37
44
  // Every table any migration watches, for the browser's topic map to cover.
38
45
  export const WATCHED_TABLES: readonly string[] = [
39
46
  ...TABLES_WATCHED_45,
@@ -41,6 +48,7 @@ export const WATCHED_TABLES: readonly string[] = [
41
48
  ...TABLES_WATCHED_56,
42
49
  ...TABLES_WATCHED_58,
43
50
  ...TABLES_WATCHED_78,
51
+ ...TABLES_WATCHED_80,
44
52
  ];
45
53
 
46
54
  export function installChangeTriggers(