@saadnvd1/agent-os 0.3.637 → 0.3.644

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 (211) hide show
  1. package/app/api/bus/branches/route.ts +25 -0
  2. package/app/api/bus/resurrect/route.test.ts +3 -6
  3. package/app/api/sessions/[id]/pr/route.ts +3 -3
  4. package/app/api/sessions/[id]/setup/route.ts +3 -3
  5. package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
  6. package/app/api/tasks/[id]/arm/route.ts +6 -5
  7. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  8. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  9. package/app/api/tasks/[id]/checks/route.ts +1 -1
  10. package/app/api/tasks/[id]/diff/route.ts +1 -1
  11. package/app/api/tasks/[id]/done/route.ts +2 -2
  12. package/app/api/tasks/[id]/drop/route.ts +6 -2
  13. package/app/api/tasks/[id]/export/route.ts +3 -5
  14. package/app/api/tasks/[id]/merge/route.ts +3 -3
  15. package/app/api/tasks/[id]/message/route.ts +2 -2
  16. package/app/api/tasks/[id]/model/route.ts +5 -1
  17. package/app/api/tasks/[id]/move/route.ts +5 -1
  18. package/app/api/tasks/[id]/moved/route.ts +6 -8
  19. package/app/api/tasks/[id]/plan/route.ts +4 -6
  20. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  21. package/app/api/tasks/[id]/priority/route.ts +4 -1
  22. package/app/api/tasks/[id]/resume/route.ts +3 -4
  23. package/app/api/tasks/[id]/stop/route.ts +2 -2
  24. package/app/api/tasks/route.ts +1 -1
  25. package/app/api/worktree-sweep/route.test.ts +40 -0
  26. package/app/api/worktree-sweep/route.ts +31 -0
  27. package/app/api/worktree-sweep/run/route.ts +26 -0
  28. package/components/Health/HealthPanel.tsx +4 -1
  29. package/components/Health/SweepRuns.tsx +125 -0
  30. package/components/Merge/GlobalMergeSection.tsx +2 -0
  31. package/components/Merge/ProjectMergeSection.tsx +2 -0
  32. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  33. package/components/Settings/NoApprovalsSection.tsx +1 -1
  34. package/components/Settings/SettingsDialog.tsx +1 -1
  35. package/data/worktree-sweep/index.ts +56 -0
  36. package/lib/approvals/enable.ts +1 -1
  37. package/lib/away/report.ts +5 -5
  38. package/lib/away/run.ts +2 -2
  39. package/lib/bookmarks/store.ts +3 -3
  40. package/lib/bus/held-reply.ts +2 -2
  41. package/lib/bus/index.ts +3 -3
  42. package/lib/chat/compact-context.ts +1 -1
  43. package/lib/chat/hold.ts +3 -3
  44. package/lib/chat/refresh.ts +1 -1
  45. package/lib/chat/registry.ts +3 -3
  46. package/lib/chat/runner.ts +1 -1
  47. package/lib/chat/worker/main.ts +1 -1
  48. package/lib/db/backup.ts +49 -0
  49. package/lib/db/index.ts +5 -1
  50. package/lib/db/lumifyhub.ts +8 -9
  51. package/lib/db/machine-scope.ts +12 -0
  52. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  53. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  54. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  55. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  56. package/lib/db/migrations/README.md +11 -0
  57. package/lib/db/queries.ts +16 -31
  58. package/lib/db/session-parts/columns.ts +186 -0
  59. package/lib/db/session-parts/fill.ts +33 -0
  60. package/lib/db/session-parts/index.ts +10 -0
  61. package/lib/db/session-parts/pauses.ts +11 -0
  62. package/lib/db/session-parts/placement.ts +11 -0
  63. package/lib/db/session-parts/plans.ts +31 -0
  64. package/lib/db/session-parts/prs.ts +31 -0
  65. package/lib/db/session-parts/rows.ts +69 -0
  66. package/lib/db/session-parts/tasks.ts +27 -0
  67. package/lib/db/session-parts/view.ts +50 -0
  68. package/lib/db/session-parts/write.ts +61 -0
  69. package/lib/done/archive.ts +2 -2
  70. package/lib/done/auto-archive.ts +1 -1
  71. package/lib/done/bulk.ts +1 -1
  72. package/lib/done/index.ts +8 -14
  73. package/lib/done/remote.ts +9 -20
  74. package/lib/health/evidence.ts +1 -1
  75. package/lib/health/facts.ts +1 -1
  76. package/lib/health/pieces.ts +1 -1
  77. package/lib/hosts/peer-actions.ts +2 -2
  78. package/lib/hosts/peer-mirror.ts +2 -2
  79. package/lib/hosts/peer-sync.ts +69 -32
  80. package/lib/learning-cards/input.ts +1 -1
  81. package/lib/learning-cards/store.ts +1 -1
  82. package/lib/models/plan-step.ts +10 -24
  83. package/lib/models/resume.ts +5 -7
  84. package/lib/notify/index.ts +1 -1
  85. package/lib/notify/push/events.ts +1 -1
  86. package/lib/notify/push/index.ts +1 -1
  87. package/lib/orchestrator/act.ts +1 -1
  88. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  89. package/lib/orchestrator/armed.ts +18 -16
  90. package/lib/orchestrator/ask-approvals.ts +1 -1
  91. package/lib/orchestrator/ask-heads.ts +1 -1
  92. package/lib/orchestrator/ask-settle.ts +1 -1
  93. package/lib/orchestrator/ask-verdict.ts +111 -0
  94. package/lib/orchestrator/asks.ts +26 -0
  95. package/lib/orchestrator/brakes.ts +1 -1
  96. package/lib/orchestrator/brief.ts +2 -1
  97. package/lib/orchestrator/digest.ts +2 -2
  98. package/lib/orchestrator/escalate.ts +25 -12
  99. package/lib/orchestrator/external-gates.ts +1 -0
  100. package/lib/orchestrator/external-pr.ts +2 -2
  101. package/lib/orchestrator/facts.ts +4 -28
  102. package/lib/orchestrator/gates.ts +94 -25
  103. package/lib/orchestrator/held-asks.ts +8 -11
  104. package/lib/orchestrator/held-review.ts +8 -4
  105. package/lib/orchestrator/home.ts +2 -2
  106. package/lib/orchestrator/loop-guard.ts +145 -0
  107. package/lib/orchestrator/merge-approvals.ts +2 -2
  108. package/lib/orchestrator/overview.ts +1 -1
  109. package/lib/orchestrator/pr-wake.ts +3 -5
  110. package/lib/orchestrator/questions.ts +1 -1
  111. package/lib/orchestrator/quick-store.ts +1 -1
  112. package/lib/orchestrator/quick.ts +11 -8
  113. package/lib/orchestrator/read.ts +1 -1
  114. package/lib/orchestrator/rebase.ts +8 -11
  115. package/lib/orchestrator/reconcile.ts +2 -2
  116. package/lib/orchestrator/review-prompt.ts +2 -0
  117. package/lib/orchestrator/review.ts +14 -4
  118. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  119. package/lib/orchestrator/signoff.ts +49 -11
  120. package/lib/orchestrator/targets.ts +1 -1
  121. package/lib/orchestrator/task-state.ts +4 -2
  122. package/lib/orchestrator/testing.ts +11 -14
  123. package/lib/orchestrator/watcher.ts +4 -0
  124. package/lib/plans/features.ts +1 -1
  125. package/lib/plans/release.ts +1 -1
  126. package/lib/ports.ts +1 -1
  127. package/lib/recipes/test-fixtures.ts +2 -3
  128. package/lib/resurrect/find.ts +1 -1
  129. package/lib/resurrect/index.ts +44 -20
  130. package/lib/resurrect/swept.ts +61 -0
  131. package/lib/schedules/store.ts +1 -1
  132. package/lib/session-audit/sources.ts +3 -3
  133. package/lib/session-logs/index.ts +1 -1
  134. package/lib/session-titles.ts +1 -1
  135. package/lib/sessions/setup-progress.ts +1 -1
  136. package/lib/sessions/stop.ts +1 -1
  137. package/lib/sessions/worktree-setup.ts +2 -1
  138. package/lib/simulator/index.ts +1 -1
  139. package/lib/simulator/task-devices.ts +1 -1
  140. package/lib/stacks/board.ts +1 -1
  141. package/lib/stacks/land.ts +1 -1
  142. package/lib/stacks/restack.ts +1 -1
  143. package/lib/stacks/start.ts +2 -2
  144. package/lib/stacks/testing.ts +17 -16
  145. package/lib/stacks/tick.ts +1 -1
  146. package/lib/stacks/view.ts +1 -1
  147. package/lib/status/incremental.ts +1 -1
  148. package/lib/tasks/contract/actions.ts +35 -0
  149. package/lib/tasks/contract/authorize.ts +82 -0
  150. package/lib/tasks/contract/client.ts +296 -0
  151. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  152. package/lib/tasks/contract/index.ts +28 -0
  153. package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
  154. package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
  155. package/lib/tasks/contract/routes.ts +26 -0
  156. package/lib/tasks/contract/start.ts +122 -0
  157. package/lib/tasks/contract/sync.ts +126 -0
  158. package/lib/tasks/contract/types.ts +41 -0
  159. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  160. package/lib/tasks/finish.ts +30 -23
  161. package/lib/tasks/held-replies.ts +7 -13
  162. package/lib/tasks/held.ts +14 -18
  163. package/lib/tasks/import.ts +19 -15
  164. package/lib/tasks/index.ts +34 -22
  165. package/lib/tasks/launch-gate.ts +1 -1
  166. package/lib/tasks/move-carry.ts +15 -7
  167. package/lib/tasks/move-flow.ts +29 -36
  168. package/lib/tasks/move-guard.ts +1 -1
  169. package/lib/tasks/move-recover.ts +6 -25
  170. package/lib/tasks/move-testing.ts +16 -7
  171. package/lib/tasks/move.ts +23 -23
  172. package/lib/tasks/moved-away.ts +1 -1
  173. package/lib/tasks/pause-hold.ts +13 -5
  174. package/lib/tasks/pause.ts +19 -23
  175. package/lib/tasks/plan-view.ts +1 -1
  176. package/lib/tasks/pr-activity.ts +11 -2
  177. package/lib/tasks/prune-branches.ts +193 -0
  178. package/lib/tasks/prune-scope.ts +22 -0
  179. package/lib/tasks/queue-placement.ts +1 -1
  180. package/lib/tasks/queue-rows.ts +4 -4
  181. package/lib/tasks/queue-start.ts +5 -3
  182. package/lib/tasks/queue-tick.ts +2 -2
  183. package/lib/tasks/queue.ts +14 -6
  184. package/lib/tasks/remote-branch.ts +170 -0
  185. package/lib/tasks/session.ts +7 -4
  186. package/lib/tasks/start.ts +4 -4
  187. package/lib/tasks/starter.ts +8 -11
  188. package/lib/tasks/testing.ts +13 -7
  189. package/lib/usage/calls.ts +1 -1
  190. package/lib/usage/compare.ts +1 -1
  191. package/lib/usage/quality.ts +1 -1
  192. package/lib/usage/turns.ts +1 -1
  193. package/lib/voice/vocab.ts +1 -1
  194. package/lib/worktree-sweep/commits.ts +65 -0
  195. package/lib/worktree-sweep/findOrphans.ts +139 -0
  196. package/lib/worktree-sweep/findSettled.ts +112 -0
  197. package/lib/worktree-sweep/index.ts +10 -0
  198. package/lib/worktree-sweep/judge.ts +31 -0
  199. package/lib/worktree-sweep/panes.ts +18 -0
  200. package/lib/worktree-sweep/runSweep.ts +243 -0
  201. package/lib/worktree-sweep/settings.ts +83 -0
  202. package/lib/worktree-sweep/start.ts +64 -0
  203. package/lib/worktree-sweep/state.ts +36 -0
  204. package/lib/worktree-sweep/store.ts +96 -0
  205. package/lib/worktree-sweep/types.ts +53 -0
  206. package/package.json +1 -1
  207. package/scripts/backfill-session-logs.ts +1 -1
  208. package/scripts/screenshots/seed.ts +19 -18
  209. package/server.ts +3 -0
  210. package/lib/tasks/held-routes.ts +0 -33
  211. package/lib/tasks/remote.ts +0 -255
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { useRunSweep, useWorktreeSweep } from "@/data/worktree-sweep";
5
+ import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
6
+ import { formatBytes } from "@/lib/share/view";
7
+ import type { SweepRun } from "@/lib/worktree-sweep/types";
8
+
9
+ const KIND: Record<string, string> = {
10
+ settled: "settled session",
11
+ orphan: "orphan folder",
12
+ "test-leak": "test leftover",
13
+ };
14
+
15
+ const name = (p: string) => p.split("/").pop();
16
+
17
+ function Run({ run }: { run: SweepRun }) {
18
+ const skipped = run.details?.skipped ?? [];
19
+ const removed = run.details?.removed ?? [];
20
+ return (
21
+ <li className="border-border/60 border-b px-3 py-1 last:border-b-0">
22
+ <details>
23
+ <summary className="flex min-h-11 cursor-pointer flex-wrap items-center gap-x-3 gap-y-1 text-sm">
24
+ <span className="font-medium">
25
+ {run.dryRun ? "Dry run, would remove" : "Removed"} {run.removed}
26
+ </span>
27
+ <span className="text-muted-foreground tabular-nums">
28
+ {formatBytes(run.freedBytes)}
29
+ </span>
30
+ <span className="text-muted-foreground">{run.skipped} skipped</span>
31
+ <span className="text-muted-foreground ml-auto text-xs">
32
+ {run.finishedAt
33
+ ? compactTimeAgo(fromSqliteTime(run.finishedAt))
34
+ : "running"}
35
+ </span>
36
+ </summary>
37
+ {run.error && (
38
+ <p role="alert" className="text-destructive mt-2 text-sm">
39
+ {run.error}
40
+ </p>
41
+ )}
42
+ {skipped.length > 0 && (
43
+ <div className="mt-2 space-y-1 pb-2">
44
+ <p className="text-sm font-medium">Skipped</p>
45
+ <ul className="text-muted-foreground space-y-1 text-xs">
46
+ {skipped.slice(0, 200).map((s) => (
47
+ <li key={s.path} className="break-all">
48
+ {name(s.path)}: {s.reason}
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </div>
53
+ )}
54
+ {removed.length > 0 && (
55
+ <div className="mt-2 space-y-1 pb-2">
56
+ <p className="text-sm font-medium">Removed</p>
57
+ <ul className="text-muted-foreground space-y-1 text-xs">
58
+ {removed.slice(0, 200).map((r) => (
59
+ <li key={r.path} className="break-all">
60
+ {name(r.path)} ({KIND[r.kind]}, {formatBytes(r.bytes)})
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ </div>
65
+ )}
66
+ </details>
67
+ </li>
68
+ );
69
+ }
70
+
71
+ // Each worktree clean-up run: what it removed, freed and skipped, and why.
72
+ export function SweepRuns() {
73
+ const { data, isPending, error } = useWorktreeSweep();
74
+ const run = useRunSweep();
75
+ return (
76
+ <div className="space-y-3">
77
+ <div className="flex flex-wrap items-center gap-2">
78
+ <p className="text-muted-foreground min-w-0 flex-1 text-sm">
79
+ Settled worktrees older than {data?.days ?? data?.defaultDays ?? 7}{" "}
80
+ days are removed every 6 hours. Branches and conversations stay.
81
+ </p>
82
+ <Button
83
+ variant="outline"
84
+ className="min-h-11 md:min-h-8"
85
+ disabled={run.isPending}
86
+ onClick={() => run.mutate(true)}
87
+ >
88
+ Dry run
89
+ </Button>
90
+ <Button
91
+ className="min-h-11 md:min-h-8"
92
+ disabled={run.isPending}
93
+ onClick={() => run.mutate(false)}
94
+ >
95
+ Run now
96
+ </Button>
97
+ </div>
98
+ {run.error && (
99
+ <p role="alert" className="text-destructive text-sm">
100
+ {run.error.message}
101
+ </p>
102
+ )}
103
+ {error && (
104
+ <p role="alert" className="text-destructive text-sm">
105
+ {error.message}
106
+ </p>
107
+ )}
108
+ {isPending && (
109
+ <div className="bg-muted/40 h-24 animate-pulse rounded-xl" />
110
+ )}
111
+ {data && data.runs.length === 0 && (
112
+ <p className="text-muted-foreground text-sm">
113
+ No run yet. The first one starts 10 minutes after the server does.
114
+ </p>
115
+ )}
116
+ {data && data.runs.length > 0 && (
117
+ <ul className="border-border/60 overflow-hidden rounded-xl border">
118
+ {data.runs.map((r) => (
119
+ <Run key={r.id} run={r} />
120
+ ))}
121
+ </ul>
122
+ )}
123
+ </div>
124
+ );
125
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "@/lib/tasks/merge-methods";
12
12
  import { MergeApprovalToggle } from "./MergeApprovalToggle";
13
13
  import { MergeSettingsFields } from "./MergeSettingsFields";
14
+ import { GlobalSweepSection } from "./WorktreeSweepSection";
14
15
 
15
16
  // "rebase, keeps worktree"
16
17
  function describe(s: MergeSettings): string {
@@ -85,6 +86,7 @@ export function GlobalMergeSection() {
85
86
  )}
86
87
  </div>
87
88
  {data && <Overrides overrides={data.overrides} />}
89
+ <GlobalSweepSection />
88
90
  </div>
89
91
  );
90
92
  }
@@ -5,6 +5,7 @@ import {
5
5
  useUpdateProjectMergeSettings,
6
6
  } from "@/data/merge";
7
7
  import { MergeSettingsFields } from "./MergeSettingsFields";
8
+ import { ProjectSweepSection } from "./WorktreeSweepSection";
8
9
 
9
10
  // A project's own merge settings, saved as they change. Unset ones inherit
10
11
  // from agentos.json, then the global settings.
@@ -42,6 +43,7 @@ export function ProjectMergeSection({
42
43
  <p className="text-destructive text-sm">{update.error.message}</p>
43
44
  )}
44
45
  <p className="text-muted-foreground text-xs">Saved as you change them.</p>
46
+ <ProjectSweepSection projectId={projectId} />
45
47
  </div>
46
48
  );
47
49
  }
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Input } from "@/components/ui/input";
5
+ import { useSetSweepDays, useWorktreeSweep } from "@/data/worktree-sweep";
6
+ import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
7
+ import { formatBytes } from "@/lib/share/view";
8
+
9
+ // Days as the box shows them; empty means "inherit".
10
+ function DaysInput({
11
+ value,
12
+ placeholder,
13
+ disabled,
14
+ onSave,
15
+ }: {
16
+ value: number | null;
17
+ placeholder: string;
18
+ disabled: boolean;
19
+ onSave: (days: number | null) => void;
20
+ }) {
21
+ const [text, setText] = useState(value === null ? "" : String(value));
22
+ const commit = () => {
23
+ const raw = text.trim();
24
+ const next = raw === "" ? null : Number(raw);
25
+ if (next === value || (next !== null && !Number.isInteger(next))) return;
26
+ onSave(next);
27
+ };
28
+ return (
29
+ <Input
30
+ aria-label="Days before a settled worktree is removed"
31
+ inputMode="numeric"
32
+ value={text}
33
+ placeholder={placeholder}
34
+ disabled={disabled}
35
+ onChange={(e) => setText(e.target.value)}
36
+ onBlur={commit}
37
+ onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
38
+ className="h-11 w-24 text-right tabular-nums md:h-9"
39
+ />
40
+ );
41
+ }
42
+
43
+ function LastRun() {
44
+ const { data } = useWorktreeSweep();
45
+ const last = data?.runs.find((r) => r.finishedAt && !r.dryRun);
46
+ if (!last) return null;
47
+ return (
48
+ <p className="text-muted-foreground text-xs">
49
+ Last run {compactTimeAgo(fromSqliteTime(last.finishedAt!))}: removed{" "}
50
+ {last.removed}, {formatBytes(last.freedBytes)} freed, {last.skipped}{" "}
51
+ skipped. Details in Health &gt; Clean-up.
52
+ </p>
53
+ );
54
+ }
55
+
56
+ // How long a settled worktree stays on disk. The branch and the conversation
57
+ // stay; restoring the session rebuilds it.
58
+ export function GlobalSweepSection() {
59
+ const { data, isPending, isError, error } = useWorktreeSweep();
60
+ const save = useSetSweepDays();
61
+ return (
62
+ <div className="space-y-2">
63
+ <h3 className="text-sm font-medium">Worktree clean-up</h3>
64
+ <p className="text-muted-foreground text-sm">
65
+ Remove the worktree of a merged, dropped, done or archived session this
66
+ many days after it settled, when nothing in it would be lost. 0 turns it
67
+ off.
68
+ </p>
69
+ {isPending && (
70
+ <div className="bg-muted/40 h-11 animate-pulse rounded-lg" />
71
+ )}
72
+ {isError && <p className="text-destructive text-sm">{error.message}</p>}
73
+ {data && (
74
+ <div className="flex items-center gap-3">
75
+ <DaysInput
76
+ key={data.days ?? "none"}
77
+ value={data.days}
78
+ placeholder={String(data.defaultDays)}
79
+ disabled={save.isPending}
80
+ onSave={(days) => save.mutate({ days })}
81
+ />
82
+ <span className="text-muted-foreground text-sm">days</span>
83
+ </div>
84
+ )}
85
+ {data && data.overrides.length > 0 && (
86
+ <p className="text-muted-foreground text-xs">
87
+ Own number:{" "}
88
+ {data.overrides.map((o) => `${o.name} (${o.days})`).join(", ")}
89
+ </p>
90
+ )}
91
+ {save.isError && (
92
+ <p className="text-destructive text-sm">{save.error.message}</p>
93
+ )}
94
+ <LastRun />
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function ProjectSweepSection({ projectId }: { projectId: string }) {
100
+ const { data } = useWorktreeSweep();
101
+ const save = useSetSweepDays();
102
+ const own =
103
+ data?.overrides.find((o) => o.projectId === projectId)?.days ?? null;
104
+ const inherited = data?.days ?? data?.defaultDays ?? 7;
105
+ return (
106
+ <div className="space-y-2">
107
+ <h3 className="text-sm font-medium">Worktree clean-up</h3>
108
+ <div className="flex items-center gap-3">
109
+ <DaysInput
110
+ key={own ?? "none"}
111
+ value={own}
112
+ placeholder={String(inherited)}
113
+ disabled={!data || save.isPending}
114
+ onSave={(days) => save.mutate({ days, projectId })}
115
+ />
116
+ <span className="text-muted-foreground text-sm">
117
+ days after a session settles (empty: {inherited}, 0: off)
118
+ </span>
119
+ </div>
120
+ {save.isError && (
121
+ <p className="text-destructive text-sm">{save.error.message}</p>
122
+ )}
123
+ </div>
124
+ );
125
+ }
@@ -8,7 +8,7 @@ import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
8
8
  // What it switches off, said plainly before it can be switched on.
9
9
  export const NO_APPROVALS_WARNING = [
10
10
  "Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
11
- "A PR that failed its gates twice, has no CI, or is too big to review still merges.",
11
+ "A PR stuck failing its gates, with no CI, or too big to review still merges.",
12
12
  "Brakes stop holding new starts, and plan-only tasks go on to implement without waiting for a plan approval.",
13
13
  "Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
14
14
  ];
@@ -59,7 +59,7 @@ export const SECTION_INFO: Record<
59
59
  > = {
60
60
  merging: {
61
61
  label: "Merging",
62
- hint: "Approval, merge method, branch clean-up",
62
+ hint: "Approval, merge method, branch and worktree clean-up",
63
63
  icon: GitMerge,
64
64
  },
65
65
  approvals: {
@@ -0,0 +1,56 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { SweepState } from "@/lib/worktree-sweep/state";
3
+
4
+ export const sweepKeys = { all: ["worktree-sweep"] as const };
5
+
6
+ async function json<T>(res: Response): Promise<T> {
7
+ const data = await res.json();
8
+ if (!res.ok) throw new Error(data.error || "Request failed");
9
+ return data;
10
+ }
11
+
12
+ export function useWorktreeSweep(enabled = true) {
13
+ return useQuery({
14
+ queryKey: sweepKeys.all,
15
+ queryFn: async () => json<SweepState>(await fetch("/api/worktree-sweep")),
16
+ enabled,
17
+ refetchInterval: 60_000,
18
+ });
19
+ }
20
+
21
+ export function useSetSweepDays() {
22
+ const queryClient = useQueryClient();
23
+ return useMutation({
24
+ mutationFn: async (v: { days: number | null; projectId?: string }) =>
25
+ json<SweepState>(
26
+ await fetch("/api/worktree-sweep", {
27
+ method: "PUT",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify(v),
30
+ })
31
+ ),
32
+ onSuccess: (data) => queryClient.setQueryData(sweepKeys.all, data),
33
+ });
34
+ }
35
+
36
+ export function useRunSweep() {
37
+ const queryClient = useQueryClient();
38
+ return useMutation({
39
+ mutationFn: async (dryRun: boolean) =>
40
+ json<{ started: boolean }>(
41
+ await fetch("/api/worktree-sweep/run", {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ dryRun }),
45
+ })
46
+ ),
47
+ onSuccess: () => {
48
+ // The run is in the background; look again as it goes.
49
+ for (const ms of [1500, 8000, 30000])
50
+ setTimeout(
51
+ () => queryClient.invalidateQueries({ queryKey: sweepKeys.all }),
52
+ ms
53
+ );
54
+ },
55
+ });
56
+ }
@@ -29,7 +29,7 @@ async function sweep(): Promise<void> {
29
29
  }
30
30
  const plans = db
31
31
  .prepare(
32
- `SELECT id, host_id, plan_waits_for_caller FROM sessions WHERE plan_phase = 'awaiting' AND task_status = 'running'`
32
+ `SELECT id, host_id, plan_waits_for_caller FROM session_rows WHERE plan_phase = 'awaiting' AND task_status = 'running'`
33
33
  )
34
34
  .all() as {
35
35
  id: string;
@@ -92,7 +92,7 @@ export function inFlightWhy(t: {
92
92
  }): string {
93
93
  const escalated = t.failures.filter((f) => f.escalated_at);
94
94
  if (escalated.length)
95
- return `with you: the ${escalated.map((f) => f.gate).join(" and ")} gate failed twice`;
95
+ return `with you: the ${escalated.map((f) => f.gate).join(" and ")} gate kept failing (the loop guard)`;
96
96
  if (t.askKinds.length) return `waiting on your answer (${t.askKinds[0]})`;
97
97
  const flagged = t.flagged
98
98
  ? `; flagged to the orchestrator ${t.flagged}x while you were away`
@@ -137,7 +137,7 @@ function merged(
137
137
  const rows = db
138
138
  .prepare(
139
139
  `SELECT s.id, s.name, p.name AS project, p.id AS project_id, s.pr_number, s.pr_url
140
- FROM sessions s JOIN projects p ON p.id = s.project_id
140
+ FROM session_rows s JOIN projects p ON p.id = s.project_id
141
141
  WHERE p.workspace_id = ? AND s.task_status = 'merged'
142
142
  AND s.merged_at >= ? AND s.merged_at <= ?
143
143
  ORDER BY s.merged_at, s.id`
@@ -178,7 +178,7 @@ function inFlight(workspaceId: string, awayId: number): InFlightItem[] {
178
178
  .prepare(
179
179
  `SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_url,
180
180
  s.merge_armed_at AS armed_at
181
- FROM sessions s JOIN projects p ON p.id = s.project_id
181
+ FROM session_rows s JOIN projects p ON p.id = s.project_id
182
182
  WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
183
183
  AND s.task_status = 'running'
184
184
  AND EXISTS (SELECT 1 FROM away_actions a WHERE a.away_id = ?
@@ -224,7 +224,7 @@ function failed(workspaceId: string, away: AwayRow, end: string) {
224
224
  const deploys = db
225
225
  .prepare(
226
226
  `SELECT a.detail, s.name, s.pr_number, s.pr_url FROM away_actions a
227
- LEFT JOIN sessions s ON s.id = a.subject
227
+ LEFT JOIN session_rows s ON s.id = a.subject
228
228
  WHERE a.away_id = ? AND a.workspace_id = ? AND a.kind = 'deploy_failed'
229
229
  ORDER BY a.id`
230
230
  )
@@ -236,7 +236,7 @@ function failed(workspaceId: string, away: AwayRow, end: string) {
236
236
  }[];
237
237
  const ended = db
238
238
  .prepare(
239
- `SELECT s.name, s.task_status, s.pr_number, s.pr_url FROM sessions s
239
+ `SELECT s.name, s.task_status, s.pr_number, s.pr_url FROM session_rows s
240
240
  JOIN projects p ON p.id = s.project_id
241
241
  WHERE p.workspace_id = ? AND s.task_status IN ('dropped', 'done')
242
242
  AND s.task_ended_at >= ? AND s.task_ended_at <= ?
package/lib/away/run.ts CHANGED
@@ -69,7 +69,7 @@ function orchestrated(): { workspaceId: string; orchestratorId: string }[] {
69
69
  }
70
70
 
71
71
  const sessionRow = (id: string) =>
72
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
72
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
73
73
  | Session
74
74
  | undefined;
75
75
 
@@ -330,7 +330,7 @@ async function checkDeploys(
330
330
  const found: DeployFinding[] = [];
331
331
  const merged = db
332
332
  .prepare(
333
- `SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id
333
+ `SELECT s.* FROM session_rows s JOIN projects p ON p.id = s.project_id
334
334
  WHERE p.workspace_id = ? AND s.task_status = 'merged' AND s.merged_at >= ?
335
335
  AND NOT EXISTS (SELECT 1 FROM away_actions a
336
336
  WHERE a.away_id = ? AND a.key = 'deploy:' || s.id)`
@@ -57,7 +57,7 @@ type Joined = Row & {
57
57
  // The bookmark with its chat's state as of now.
58
58
  const SELECT = `
59
59
  SELECT b.*, s.id AS s_id, s.task_status, s.archived_at
60
- FROM chat_bookmarks b LEFT JOIN sessions s ON s.id = b.session_id`;
60
+ FROM chat_bookmarks b LEFT JOIN session_rows s ON s.id = b.session_id`;
61
61
 
62
62
  function toBookmark(r: Joined): Bookmark {
63
63
  return {
@@ -141,7 +141,7 @@ export function createBookmark(input: {
141
141
  throw new BookmarkError("scope must be 'item' or 'turn'", 400);
142
142
  const note = cleanNote(input.note);
143
143
  const session = db
144
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
144
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
145
145
  .get(sessionId) as Session | undefined;
146
146
  if (!session) throw new BookmarkError("Session not found", 404);
147
147
  const existing = db
@@ -308,7 +308,7 @@ export function insertMovedBookmarks(
308
308
  ): void {
309
309
  if (!rows.length) return;
310
310
  const session = db
311
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
311
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
312
312
  .get(sessionId) as Session | undefined;
313
313
  if (!session) return;
314
314
  const workspace = sessionWorkspace(session);
@@ -68,7 +68,7 @@ export function pendingReplies(device: string, limit = 50): PendingReply[] {
68
68
  db
69
69
  .prepare(
70
70
  `SELECT m.id, m.from_id AS fromId, m.body, m.created_at AS createdAt
71
- FROM bus_messages m JOIN sessions s ON s.id = m.from_id
71
+ FROM bus_messages m JOIN session_rows s ON s.id = m.from_id
72
72
  WHERE m.relay_state = 'pending' AND s.held_by_device = ?
73
73
  ORDER BY m.id LIMIT ?`
74
74
  )
@@ -81,7 +81,7 @@ export function ackReplies(device: string, ids: number[]): void {
81
81
  const mark = db.prepare(
82
82
  `UPDATE bus_messages SET relay_state = 'relayed', delivered_at = datetime('now')
83
83
  WHERE id = ? AND relay_state = 'pending'
84
- AND from_id IN (SELECT id FROM sessions WHERE held_by_device = ?)`
84
+ AND from_id IN (SELECT id FROM session_rows WHERE held_by_device = ?)`
85
85
  );
86
86
  db.transaction(() => {
87
87
  for (const id of ids) mark.run(id, device);
package/lib/bus/index.ts CHANGED
@@ -15,7 +15,7 @@ import { deliverToPane, type Delivery } from "./delivery";
15
15
  import { resolveRef, wasNames, type Candidate } from "./resolve";
16
16
  import { tmuxPane } from "./tmux-pane";
17
17
  import { pauseHoldOf, pausedNote } from "../tasks/pause-hold";
18
- import { isHeldRemote, relayToHeldTask } from "../tasks/held-remote";
18
+ import { isHeldRemote, relayToHeldTask } from "../tasks/contract/held-calls";
19
19
  import {
20
20
  assertTakesMessages,
21
21
  findQueuedTarget,
@@ -43,7 +43,7 @@ export interface Peer {
43
43
  function liveSessions(): Session[] {
44
44
  return db
45
45
  .prepare(
46
- `SELECT * FROM sessions WHERE (task_status IS NULL OR task_status = 'running')
46
+ `SELECT * FROM session_rows WHERE (task_status IS NULL OR task_status = 'running')
47
47
  AND archived_at IS NULL
48
48
  ORDER BY updated_at DESC`
49
49
  )
@@ -63,7 +63,7 @@ function candidates(sessions: Session[]): Candidate[] {
63
63
 
64
64
  const sessionById = (id: string | null | undefined) =>
65
65
  id
66
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
66
+ ? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
67
67
  | Session
68
68
  | undefined)
69
69
  : undefined;
@@ -28,7 +28,7 @@ async function gitStatus(cwd: string): Promise<string> {
28
28
  export async function compactContext(sessionId: string): Promise<string> {
29
29
  const session = db
30
30
  .prepare(
31
- `SELECT working_directory, task_prompt, role FROM sessions WHERE id = ?`
31
+ `SELECT working_directory, task_prompt, role FROM session_rows WHERE id = ?`
32
32
  )
33
33
  .get(sessionId) as
34
34
  | Pick<Session, "working_directory" | "task_prompt" | "role">
package/lib/chat/hold.ts CHANGED
@@ -15,10 +15,10 @@ import { db } from "../db";
15
15
 
16
16
  // The SQL form, for queries that take the session id as @sid.
17
17
  export const HELD_SQL = `(
18
- EXISTS (SELECT 1 FROM sessions WHERE id = @sid AND task_status = 'moving')
18
+ EXISTS (SELECT 1 FROM session_rows WHERE id = @sid AND task_status = 'moving')
19
19
  OR EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
20
20
  WHERE i.session_id = @sid AND s.status = 'landing')
21
- OR EXISTS (SELECT 1 FROM sessions WHERE id = @sid
21
+ OR EXISTS (SELECT 1 FROM session_rows WHERE id = @sid
22
22
  AND pause_state IN ('pausing', 'paused'))
23
23
  )`;
24
24
 
@@ -58,7 +58,7 @@ const holdRow = (sessionId: string) =>
58
58
  `SELECT task_status, moved_to, quick_of, pause_state, paused_until,
59
59
  EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
60
60
  WHERE i.session_id = @sid AND s.status = 'landing') AS landing
61
- FROM sessions WHERE id = @sid`
61
+ FROM session_rows WHERE id = @sid`
62
62
  )
63
63
  .get({ sid: sessionId }) as HoldRow | undefined;
64
64
 
@@ -163,7 +163,7 @@ export function autoCompactEnv(sessionId: string): Record<string, string> {
163
163
  const row = db
164
164
  .prepare(
165
165
  `SELECT role, task_status, plan_phase, started_by, model_provider
166
- FROM sessions WHERE id = ?`
166
+ FROM session_rows WHERE id = ?`
167
167
  )
168
168
  .get(sessionId) as
169
169
  | {
@@ -112,9 +112,9 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
112
112
  }
113
113
 
114
114
  export function getSession(sessionId: string): Session {
115
- const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
116
- | Session
117
- | undefined;
115
+ const s = db
116
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
117
+ .get(sessionId) as Session | undefined;
118
118
  if (!s) throw new Error("Session not found");
119
119
  return s;
120
120
  }
@@ -549,7 +549,7 @@ async function resumeCutOff(sessionId: string): Promise<void> {
549
549
  .prepare(
550
550
  `SELECT s.name, s.view, s.archived_at, s.task_status, s.role,
551
551
  COALESCE(s.workspace_id, p.workspace_id) AS workspace_id
552
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
552
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
553
553
  WHERE s.id = ?`
554
554
  )
555
555
  .get(sessionId) as
@@ -30,7 +30,7 @@ async function main(sessionId: string) {
30
30
  const { ChatHost } = await import("./host");
31
31
 
32
32
  const row = db
33
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
33
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
34
34
  .get(sessionId) as Session | undefined;
35
35
  if (!row) {
36
36
  console.error(`No session ${sessionId}`);
@@ -0,0 +1,49 @@
1
+ import type Database from "better-sqlite3";
2
+ import Sqlite from "better-sqlite3";
3
+ import { randomBytes } from "crypto";
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import path from "path";
7
+ import { refuseRealStateUnderTest } from "../test-home-guard";
8
+
9
+ export const backupsDir = (): string =>
10
+ refuseRealStateUnderTest(path.join(os.homedir(), ".agent-os", "backups"));
11
+
12
+ // A full copy of the database (VACUUM INTO) under ~/.agent-os/backups, taken
13
+ // before a destructive migration. Throws, and the migration must stop, unless
14
+ // the copy exists and holds every session. The message never says "already
15
+ // exists": the migration runner reads that as applied.
16
+ export function backupDatabase(
17
+ db: Database.Database,
18
+ label: string,
19
+ dir: string = backupsDir()
20
+ ): string {
21
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
22
+ const file = path.join(
23
+ dir,
24
+ `agent-os-${stamp}-${process.pid}-${randomBytes(3).toString("hex")}-${label}.db`
25
+ );
26
+ try {
27
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
28
+ db.prepare(`VACUUM INTO ?`).run(file);
29
+ fs.chmodSync(file, 0o600);
30
+ const copy = new Sqlite(file, { readonly: true });
31
+ try {
32
+ const count = (sql: Database.Database) =>
33
+ (
34
+ sql.prepare(`SELECT COUNT(*) AS n FROM sessions`).get() as {
35
+ n: number;
36
+ }
37
+ ).n;
38
+ if (count(copy) !== count(db)) throw new Error("session count differs");
39
+ } finally {
40
+ copy.close();
41
+ }
42
+ } catch (error) {
43
+ const reason = error instanceof Error ? error.message : String(error);
44
+ throw new Error(
45
+ `Backup before ${label} failed (${file}): ${reason.replace(/already exists/gi, "is present")}. Nothing was changed.`
46
+ );
47
+ }
48
+ return file;
49
+ }
package/lib/db/index.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  import Database from "better-sqlite3";
2
- import path from "path";
3
2
  import fs from "fs";
4
3
  import { createSchema } from "./schema";
5
4
  import { runMigrations } from "./migrations";
6
5
  import { dbPath } from "./path";
7
6
 
7
+ import { machineDb } from "./machine-scope";
8
+
8
9
  // Re-export types and queries
9
10
  export * from "./types";
10
11
  export { queries, sessionListColumns } from "./queries";
11
12
  export * from "./lumifyhub";
12
13
  export * from "./lumifyhub-docs";
13
14
  export * from "./stacks";
15
+ export * from "./session-parts";
14
16
 
15
17
  const DB_PATH = dbPath();
16
18
  const LOCK_PATH = DB_PATH + ".init-lock";
@@ -89,6 +91,8 @@ export function initDb(): Database.Database {
89
91
  let _db: Database.Database | null = null;
90
92
 
91
93
  export function getDb(): Database.Database {
94
+ const scoped = machineDb.getStore();
95
+ if (scoped) return scoped;
92
96
  if (!_db) {
93
97
  _db = initDb();
94
98
  }