@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,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
  [
@@ -21,13 +21,8 @@ import {
21
21
  serverPort,
22
22
  setConnectEnabled,
23
23
  } from "./config";
24
- import { enrol } from "./enrol";
25
- import {
26
- checkApiUrl,
27
- DEFAULT_API,
28
- registerWithCode,
29
- requestTunnel,
30
- } from "./control-api";
24
+ import { linkWithCode } from "./link";
25
+ import { checkApiUrl, requestTunnel } from "./control-api";
31
26
 
32
27
  const arg = (flag: string) => {
33
28
  const i = process.argv.indexOf(flag);
@@ -83,23 +78,16 @@ async function main() {
83
78
  config = { ...config, hostname };
84
79
  saveConnect(config, dir);
85
80
  } else {
86
- const apiUrl = checkApiUrl(
87
- process.env.CONNECT_API_URL || DEFAULT_API,
88
- operator
89
- );
90
81
  const code = await linkCode();
91
82
  if (!code)
92
83
  throw new Error(
93
84
  "get a code from runagentos.com (Connect a machine), then run agent-os connect and paste it"
94
85
  );
95
- config = await enrol({
86
+ config = await linkWithCode({
96
87
  dir,
97
- register: registerWithCode(
98
- apiUrl,
99
- code,
100
- arg("--name") || os.hostname(),
101
- port
102
- ),
88
+ code,
89
+ name: arg("--name") || os.hostname(),
90
+ port,
103
91
  });
104
92
  }
105
93
  console.log(`\n Machine ${config.machineId}`);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Linking this machine to the Connect service with a code from
3
+ * runagentos.com. Shared by `agent-os connect` and the setup's link form.
4
+ */
5
+
6
+ import { type ConnectConfig } from "./config";
7
+ import { DEFAULT_API, checkApiUrl, registerWithCode } from "./control-api";
8
+ import { enrol } from "./enrol";
9
+
10
+ /** The code as typed, or null when it can't be one. */
11
+ export function cleanLinkCode(raw: unknown): string | null {
12
+ if (typeof raw !== "string") return null;
13
+ const code = raw.trim();
14
+ if (!code || code.length > 128 || /[\s\u0000-\u001f\u007f-\u009f]/.test(code))
15
+ return null;
16
+ return code;
17
+ }
18
+
19
+ export async function linkWithCode(opts: {
20
+ code: string;
21
+ name: string;
22
+ port: number;
23
+ dir?: string;
24
+ env?: NodeJS.ProcessEnv;
25
+ }): Promise<ConnectConfig> {
26
+ const env = opts.env ?? process.env;
27
+ const apiUrl = checkApiUrl(
28
+ env.CONNECT_API_URL || DEFAULT_API,
29
+ env.AGENTOS_CONNECT_OPERATOR === "1"
30
+ );
31
+ return enrol({
32
+ dir: opts.dir,
33
+ register: registerWithCode(apiUrl, opts.code, opts.name, opts.port),
34
+ });
35
+ }
@@ -195,7 +195,18 @@ export function startConnect(
195
195
  ? "AGENTOS_CONNECT_PORT isn't a usable port"
196
196
  : decision.reason;
197
197
  if (readConnect(dir).ok) log(`connect: not starting: ${reason}`);
198
- base = { state: "refused", reason };
198
+ // Still says whether the machine is linked, read when asked.
199
+ g.__agentosConnect = () => {
200
+ const r = readConnect(dir);
201
+ return {
202
+ state: "refused",
203
+ reason,
204
+ ...(r.ok && {
205
+ enrolled: true,
206
+ hostname: r.files.config.hostname || undefined,
207
+ }),
208
+ };
209
+ };
199
210
  return { hostname: () => null as string | null, close: () => {} };
200
211
  }
201
212
 
@@ -0,0 +1,28 @@
1
+ import type { ConnectStatus } from "./serve";
2
+
3
+ export const CONNECT_SITE = "https://runagentos.com";
4
+
5
+ export const CONNECT_STATE: Record<ConnectStatus["state"], string> = {
6
+ up: "Connected",
7
+ connecting: "Connecting…",
8
+ denied: "Refused by Connect",
9
+ off: "Off",
10
+ refused: "Not running here",
11
+ error: "Problem",
12
+ };
13
+
14
+ /** What the UI needs to know about Connect on this machine. */
15
+ export function connectView(s: ConnectStatus) {
16
+ const linked = !!s.enrolled || !!s.hostname;
17
+ const on =
18
+ s.state === "up" ||
19
+ s.state === "connecting" ||
20
+ s.state === "denied" ||
21
+ (s.state === "error" && !!s.enrolled);
22
+ return {
23
+ linked,
24
+ on,
25
+ canToggle: linked && s.state !== "refused",
26
+ hostname: s.hostname,
27
+ };
28
+ }
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(
@@ -5,6 +5,7 @@ import {
5
5
  TABLES_WATCHED_56,
6
6
  TABLES_WATCHED_58,
7
7
  TABLES_WATCHED_78,
8
+ TABLES_WATCHED_80,
8
9
  } from "./changes";
9
10
  import type Database from "better-sqlite3";
10
11
 
@@ -1830,6 +1831,132 @@ const migrations: Migration[] = [
1830
1831
  if (!has) db.exec(`ALTER TABLE task_queue ADD COLUMN agent_type TEXT`);
1831
1832
  },
1832
1833
  },
1834
+ {
1835
+ id: 80,
1836
+ name: "recipes",
1837
+ up: (db) => {
1838
+ // Recipes: a saved brief that starts a fresh task or session each run
1839
+ // (lib/recipes), and the features a plan splits into pieces
1840
+ // (lib/plans). All additive. A run or piece finds its session by
1841
+ // session_id, so sessions gains no column.
1842
+ db.exec(`
1843
+ CREATE TABLE IF NOT EXISTS recipes (
1844
+ id TEXT PRIMARY KEY,
1845
+ workspace_id TEXT NOT NULL,
1846
+ project_id TEXT NOT NULL,
1847
+ name TEXT NOT NULL,
1848
+ kind TEXT NOT NULL,
1849
+ brief TEXT NOT NULL,
1850
+ model TEXT,
1851
+ view TEXT NOT NULL DEFAULT 'chat',
1852
+ auto_done INTEGER NOT NULL DEFAULT 1,
1853
+ max_minutes INTEGER,
1854
+ carry_cleared_at INTEGER,
1855
+ source_session_id TEXT,
1856
+ created_by_session_id TEXT,
1857
+ updated_by_session_id TEXT,
1858
+ archived_at TEXT,
1859
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1860
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1861
+ );
1862
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_name
1863
+ ON recipes(workspace_id, name COLLATE NOCASE) WHERE archived_at IS NULL;
1864
+
1865
+ CREATE TABLE IF NOT EXISTS recipe_runs (
1866
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1867
+ recipe_id TEXT NOT NULL,
1868
+ seq INTEGER NOT NULL,
1869
+ trigger TEXT NOT NULL,
1870
+ triggered_by_session_id TEXT,
1871
+ schedule_run_id INTEGER,
1872
+ request_id TEXT,
1873
+ note TEXT,
1874
+ prompt TEXT NOT NULL,
1875
+ revision_id INTEGER,
1876
+ status TEXT NOT NULL,
1877
+ session_id TEXT,
1878
+ pr_number INTEGER,
1879
+ pr_url TEXT,
1880
+ summary TEXT,
1881
+ outputs TEXT,
1882
+ summary_source TEXT,
1883
+ error TEXT,
1884
+ cost_usd REAL,
1885
+ duration_ms INTEGER,
1886
+ started_at TEXT,
1887
+ ended_at TEXT,
1888
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1889
+ UNIQUE (recipe_id, seq)
1890
+ );
1891
+ CREATE INDEX IF NOT EXISTS idx_recipe_runs_recipe
1892
+ ON recipe_runs(recipe_id, id DESC);
1893
+ CREATE INDEX IF NOT EXISTS idx_recipe_runs_session
1894
+ ON recipe_runs(session_id);
1895
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipe_runs_one_live
1896
+ ON recipe_runs(recipe_id)
1897
+ WHERE status IN ('queued','starting','running');
1898
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipe_runs_request
1899
+ ON recipe_runs(request_id) WHERE request_id IS NOT NULL;
1900
+
1901
+ CREATE TABLE IF NOT EXISTS recipe_revisions (
1902
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1903
+ recipe_id TEXT NOT NULL,
1904
+ brief TEXT NOT NULL,
1905
+ settings TEXT NOT NULL,
1906
+ edited_by_session_id TEXT,
1907
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1908
+ );
1909
+
1910
+ CREATE TABLE IF NOT EXISTS features (
1911
+ id TEXT PRIMARY KEY,
1912
+ workspace_id TEXT NOT NULL,
1913
+ name TEXT NOT NULL,
1914
+ plan_session_id TEXT NOT NULL,
1915
+ plan_text TEXT NOT NULL,
1916
+ status TEXT NOT NULL,
1917
+ done_at TEXT,
1918
+ cost_usd REAL,
1919
+ duration_ms INTEGER,
1920
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1921
+ );
1922
+ CREATE INDEX IF NOT EXISTS idx_features_plan
1923
+ ON features(plan_session_id);
1924
+
1925
+ CREATE TABLE IF NOT EXISTS feature_pieces (
1926
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1927
+ feature_id TEXT NOT NULL,
1928
+ position INTEGER NOT NULL,
1929
+ name TEXT NOT NULL,
1930
+ slice TEXT NOT NULL,
1931
+ project_id TEXT NOT NULL,
1932
+ after_position INTEGER,
1933
+ after_kind TEXT,
1934
+ reasons TEXT,
1935
+ owns TEXT,
1936
+ status TEXT NOT NULL,
1937
+ session_id TEXT,
1938
+ note TEXT,
1939
+ UNIQUE (feature_id, position)
1940
+ );
1941
+ CREATE INDEX IF NOT EXISTS idx_feature_pieces_session
1942
+ ON feature_pieces(session_id);
1943
+ `);
1944
+ const cols = (
1945
+ db.prepare(`PRAGMA table_info(schedules)`).all() as { name: string }[]
1946
+ ).map((c) => c.name);
1947
+ if (!cols.includes("recipe_id"))
1948
+ db.exec(`ALTER TABLE schedules ADD COLUMN recipe_id TEXT`);
1949
+ if (!cols.includes("migrated_from_kind"))
1950
+ db.exec(`ALTER TABLE schedules ADD COLUMN migrated_from_kind TEXT`);
1951
+ const plan = (
1952
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
1953
+ ).map((c) => c.name);
1954
+ // Edits Saad made to a plan's pieces before approving it (merge, drop).
1955
+ if (!plan.includes("plan_edits"))
1956
+ db.exec(`ALTER TABLE sessions ADD COLUMN plan_edits TEXT`);
1957
+ installChangeTriggers(db, TABLES_WATCHED_80);
1958
+ },
1959
+ },
1833
1960
  ];
1834
1961
 
1835
1962
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/queries.ts CHANGED
@@ -32,7 +32,10 @@ export function sessionListColumns(db: Database.Database, alias = ""): string {
32
32
  .split(",")
33
33
  .map((c) => `${prefix}${c}`)
34
34
  .join(", ");
35
- return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief`;
35
+ // The recipe run or feature piece a session is, as JSON, for its row's chip.
36
+ const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
37
+ const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "sessions"}.id)`;
38
+ return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
36
39
  }
37
40
 
38
41
  export const queries = {
package/lib/db/types.ts CHANGED
@@ -26,6 +26,8 @@ export interface Session {
26
26
  started_by?: string | null;
27
27
  // 1 planned, 0 no plan, null not stated; why, in the starter's words.
28
28
  plan_decided?: number | null;
29
+ // JSON: the pieces Saad merged or dropped before approving the plan.
30
+ plan_edits?: string | null;
29
31
  plan_reason?: string | null;
30
32
  system_prompt: string | null;
31
33
  group_path: string; // Deprecated - use project_id
@@ -55,6 +57,10 @@ export interface Session {
55
57
  task_brief?: string | null;
56
58
  // Lists carry this instead of the brief itself (0/1).
57
59
  has_brief?: number;
60
+ // JSON, in lists: the recipe run this session is ({id, name, seq}), and the
61
+ // feature piece it builds ({feature, name, position, total}).
62
+ recipe_run?: string | null;
63
+ feature_piece?: string | null;
58
64
  // How the session is shown: a chat conversation or a terminal.
59
65
  view: "chat" | "terminal";
60
66
  chat_access: ChatAccess;